From c3d0c38fd158fccb313afa5a42d500f00b71ed32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:24:47 +0900 Subject: [PATCH 01/15] feat(psychometric): posterior ESEM input gates with true-parameter RMSE ADR 0005 first production slice: construct classification, refusal of raw-proportion Pearson, CPU f64 OLS and plausible-value loading recovery, invariance-gated mean comparison, and causal-heuristic refusal. No new migration. --- ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + Cargo.lock | 4 + Cargo.toml | 2 + DOCUMENTATION.md | 1 + README.md | 6 +- crates/psychometric_core/Cargo.toml | 19 ++ crates/psychometric_core/src/causality.rs | 65 +++++ crates/psychometric_core/src/construct.rs | 95 +++++++ crates/psychometric_core/src/error.rs | 85 ++++++ crates/psychometric_core/src/indicator.rs | 135 +++++++++ crates/psychometric_core/src/lib.rs | 43 +++ crates/psychometric_core/src/loading.rs | 64 +++++ crates/psychometric_core/src/plausible.rs | 69 +++++ .../psychometric_core/tests/crate_contract.rs | 7 + .../tests/esem_input_recovery_contract.rs | 267 ++++++++++++++++++ docs/TRACEABILITY.md | 2 +- docs/adr/0005-posterior-esem-dsem.md | 2 +- docs/adr/README.md | 2 +- docs/research/posterior-esem-input-gates.md | 42 +++ docs/research/standards-and-literature.md | 8 +- docs/validation/temporal-event-foundation.md | 1 + scripts/check_workspace_contract.py | 1 + tests/quality/test_check_docstrings.py | 3 +- 24 files changed, 917 insertions(+), 8 deletions(-) create mode 100644 crates/psychometric_core/Cargo.toml create mode 100644 crates/psychometric_core/src/causality.rs create mode 100644 crates/psychometric_core/src/construct.rs create mode 100644 crates/psychometric_core/src/error.rs create mode 100644 crates/psychometric_core/src/indicator.rs create mode 100644 crates/psychometric_core/src/lib.rs create mode 100644 crates/psychometric_core/src/loading.rs create mode 100644 crates/psychometric_core/src/plausible.rs create mode 100644 crates/psychometric_core/tests/crate_contract.rs create mode 100644 crates/psychometric_core/tests/esem_input_recovery_contract.rs create mode 100644 docs/research/posterior-esem-input-gates.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe514db..18115747 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 ESEM/DSEM input gates and CPU `f64` loading 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 9abfea7e..74fb9d23 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 ESEM/DSEM input gates: construct classification, refusal of raw-proportion Pearson, CPU `f64` OLS and plausible-value loading recovery with computed RMSE, invariance-gated latent-mean comparison, and refusal of causal language from temporal precedence, document linkage, event tracking, or prediction (ADR 0005 first production slice; no new migration). - `persistence_postgres` typed membership assignment (migration `0006`): `entity_record`, `project_record`, and `text_segment` plus exactly-one observed-unit and target constraints that replace the polymorphic `membership_target_id` stub, with SQL insert/lookup, fail-closed inverted-window and backslash-label refusal, and live proof that one document persists two entity memberships and one project membership. - Actions workflow fleet auditor (`scripts/actions_workflow_fleet.py`): paginated registry inventory bound to the exact default-branch SHA/tree, classification of present/orphan/disabled/GitHub-dynamic identities, and fail-closed orphan disable that confirms GitHub's official `disabled_manually` state. - `persistence_postgres` temporal interval ordering migration (`0005`): multi-word CHECK constraints on `document_record`, `event_instance`, and `membership_assignment` that reject inverted valid/system windows and non-positive document revisions while preserving open-ended NULL upper bounds and equal point bounds; catalog validation and live inverted-window proof. diff --git a/Cargo.lock b/Cargo.lock index 372a55f4..93fccaa1 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 230c5abe..3f3318f5 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) | | Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | diff --git a/README.md b/README.md index ae74015d..de411879 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 ``` ## 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..51568f22 --- /dev/null +++ b/crates/psychometric_core/src/indicator.rs @@ -0,0 +1,135 @@ +//! Valid psychometric indicator coordinates. + +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 a valid Euclidean psychometric input. + #[must_use] + pub const fn is_valid_psychometric_input(self) -> bool { + !matches!(self, Self::RawProportion) + } +} + +/// 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_psychometric_input() { + Ok(()) + } else { + Err(PsychometricError::RawProportionForbidden) + } +} + +/// Pearson product-moment correlation on already-mapped coordinates. +/// +/// # 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!( + pearson_correlation(&[1.0, 2.0], &[3.0, 3.0], IndicatorKind::LogisticNormal), + Err(PsychometricError::SingularDesign) + ); + 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..1587ef1f --- /dev/null +++ b/crates/psychometric_core/src/lib.rs @@ -0,0 +1,43 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Posterior-aware psychometric input gates for ESEM/DSEM. +//! +//! Raw topic proportions are not Euclidean indicators. This crate classifies +//! constructs, admits only log-ratio or logistic-normal coordinates, aggregates +//! plausible-value loadings on a CPU `f64` path, and refuses causal language +//! from temporal precedence, document linkage, event tracking, or prediction. + +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 plausible-value draws. +pub use plausible::plausible_value_mean; +/// Average OLS loadings across posterior indicator draws. +pub use plausible::recover_loading_from_plausible_values; diff --git a/crates/psychometric_core/src/loading.rs b/crates/psychometric_core/src/loading.rs new file mode 100644 index 00000000..a45dd328 --- /dev/null +++ b/crates/psychometric_core/src/loading.rs @@ -0,0 +1,64 @@ +//! 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) + ); + } +} diff --git a/crates/psychometric_core/src/plausible.rs b/crates/psychometric_core/src/plausible.rs new file mode 100644 index 00000000..c6ba9538 --- /dev/null +++ b/crates/psychometric_core/src/plausible.rs @@ -0,0 +1,69 @@ +//! Plausible-value aggregation of 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 plausible-value draws. +/// +/// # Errors +/// +/// Returns [`PsychometricError::InvalidNumericInput`] when `draws` is empty or +/// contains a non-finite value. +pub fn plausible_value_mean(draws: &[f64]) -> Result { + if draws.is_empty() { + return Err(PsychometricError::InvalidNumericInput); + } + let mut sum = 0.0_f64; + for &value in draws { + if !value.is_finite() { + return Err(PsychometricError::InvalidNumericInput); + } + sum += value; + } + require_finite(sum / draws.len() as f64) +} + +/// Recover a reflective loading by averaging OLS slopes across posterior +/// indicator draws (Rubin-style plausible values). +/// +/// # 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_from_plausible_values( + 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)?); + } + plausible_value_mean(&recovered) +} + +#[cfg(test)] +mod tests { + use super::{plausible_value_mean, recover_loading_from_plausible_values}; + use crate::error::PsychometricError; + use crate::indicator::IndicatorKind; + + #[test] + fn mean_of_two_draws_and_nonfinite_mean_fail_closed() { + let mean = plausible_value_mean(&[1.0, 3.0]).expect("mean"); + assert!((mean - 2.0).abs() < 1e-15); + assert_eq!( + recover_loading_from_plausible_values( + &[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..414c0077 --- /dev/null +++ b/crates/psychometric_core/tests/esem_input_recovery_contract.rs @@ -0,0 +1,267 @@ +//! 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, plausible_value_mean, recover_loading_from_plausible_values, + 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 plausible_value_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_from_plausible_values( + &factor_scores, + &indicator_draws, + IndicatorKind::LogisticNormal, + ) + .expect("plausible-value loading"); + let pooled_error = rmse(&[true_loading], &[pooled]); + assert!( + pooled_error < 1e-12, + "symmetric plausible-value 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_from_plausible_values( + &[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!( + pearson_correlation(&[1.0, 1.0], &[2.0, 3.0], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::SingularDesign) + ); + assert_eq!( + plausible_value_mean(&[]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + plausible_value_mean(&[1.0, f64::INFINITY]), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_loading_from_plausible_values(&[1.0, 2.0], &[], IndicatorKind::AdditiveLogRatio), + Err(PsychometricError::InvalidNumericInput) + ); + assert_eq!( + recover_loading_from_plausible_values( + &[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_psychometric_input()); + assert!(IndicatorKind::IsometricLogRatio.is_valid_psychometric_input()); + assert!(IndicatorKind::LogisticNormal.is_valid_psychometric_input()); + assert!(!IndicatorKind::RawProportion.is_valid_psychometric_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 = plausible_value_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/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 051062ea..544c7d07 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` input gates, plausible-value loading recovery, and causal-refusal on the active PR; full ESEM/DSEM estimator 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 | future `interpretation_gateway` | accepted-target | diff --git a/docs/adr/0005-posterior-esem-dsem.md b/docs/adr/0005-posterior-esem-dsem.md index 09e5b0ce..3cef4c6f 100644 --- a/docs/adr/0005-posterior-esem-dsem.md +++ b/docs/adr/0005-posterior-esem-dsem.md @@ -1,7 +1,7 @@ # ADR 0005 — Posterior-aware ESEM/DSEM and structural interpretation **Decision status:** Accepted -**Implementation maturity:** accepted-target +**Implementation maturity:** partial — construct classification, valid log-ratio/logistic-normal indicator gates, CPU `f64` OLS and plausible-value loading recovery, 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1a9a7b31..a85efef9 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, plausible-value loading recovery, and causal-refusal are on the active PR; 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/research/posterior-esem-input-gates.md b/docs/research/posterior-esem-input-gates.md new file mode 100644 index 00000000..cfc29848 --- /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 only additive log-ratio, isometric log-ratio, or logistic-normal coordinates; +4. recover a reflective loading by ordinary least squares on a CPU `f64` path; +5. average recovered loadings across posterior indicator draws (plausible values); +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. +- **Plausible-value loading** is the arithmetic mean of \(\hat\lambda_d\) across posterior indicator draws (Mislevy, 1991). +- **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 plausible-value draw noise cancels in the pooled loading 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 b4b14468..3f7c083d 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 984d329c..52314432 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -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 | — | unknown-field/version/limit tests | Task 12 / PR #21; HTTP service remaining | +| Posterior ESEM/DSEM input gates | `psychometric_core` | active-PR | construct class + PV loading RMSE | computed loading RMSE + causal refusal | ADR 0005; `docs/research/posterior-esem-input-gates.md` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 | 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), []) From 3acbc81aa6ac6a6bd26b22360822ec6664534247 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:40:04 +0900 Subject: [PATCH 02/15] test(psychometric): expose ALR geometry and Rubin overclaim gaps --- .../scientific_claim_boundary_contract.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 crates/psychometric_core/tests/scientific_claim_boundary_contract.rs 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); +} From 18b37cf5e155cfa0562ef79d60a8618056405082 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:42:44 +0900 Subject: [PATCH 03/15] fix(psychometric): script honest geometry and posterior claims --- scripts/repair_pr49_scientific_claims.py | 294 +++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 scripts/repair_pr49_scientific_claims.py diff --git a/scripts/repair_pr49_scientific_claims.py b/scripts/repair_pr49_scientific_claims.py new file mode 100644 index 00000000..aa21fd2b --- /dev/null +++ b/scripts/repair_pr49_scientific_claims.py @@ -0,0 +1,294 @@ +"""Apply PR 49 compositional-geometry and posterior-summary claim repairs.""" + +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + """Replace exactly one fragment or fail closed.""" + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one target, found {count}") + return text.replace(old, new, 1) + + +def update_indicator_contract() -> None: + """Distinguish valid structural coordinates from Aitchison isometries.""" + path = Path("crates/psychometric_core/src/indicator.rs") + text = path.read_text(encoding="utf-8") + text = replace_once( + text, + "//! Valid psychometric indicator coordinates.\n", + "//! Valid structural indicator coordinates and compositional-geometry claims.\n", + "indicator module docs", + ) + old_block = """ /// Return whether the kind is a valid Euclidean psychometric input. + #[must_use] + pub const fn is_valid_psychometric_input(self) -> bool { + !matches!(self, Self::RawProportion) + } +""" + new_block = """ /// 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) + } +""" + text = replace_once(text, old_block, new_block, "indicator geometry methods") + text = text.replace("kind.is_valid_psychometric_input()", "kind.is_valid_structural_input()") + text = replace_once( + text, + "/// Pearson product-moment correlation on already-mapped coordinates.\n", + """/// 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. +""", + "Pearson claim boundary", + ) + path.write_text(text, encoding="utf-8") + + +def update_posterior_summary_contract() -> None: + """Rename point-estimate averages so they cannot imply Rubin pooling.""" + path = Path("crates/psychometric_core/src/plausible.rs") + text = path.read_text(encoding="utf-8") + text = replace_once( + text, + "//! Plausible-value aggregation of posterior structural draws.\n", + "//! Point-estimate aggregation across posterior structural draws.\n", + "posterior module docs", + ) + text = text.replace("plausible_value_mean", "posterior_draw_point_estimate_mean") + text = text.replace( + "recover_loading_from_plausible_values", + "recover_loading_point_estimate_mean", + ) + text = replace_once( + text, + "/// Arithmetic mean of finite plausible-value draws.\n", + """/// 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. +""", + "point-estimate mean docs", + ) + text = replace_once( + text, + """/// Recover a reflective loading by averaging OLS slopes across posterior +/// indicator draws (Rubin-style plausible values). +""", + """/// 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. +""", + "loading aggregation docs", + ) + text = text.replace("plausible-value loading", "posterior-draw loading point estimate") + text = text.replace("mean_of_two_draws", "mean_of_two_point_estimates") + path.write_text(text, encoding="utf-8") + + +def update_public_api_and_tests() -> None: + """Align exports and recovery tests with the narrower scientific claims.""" + lib_path = Path("crates/psychometric_core/src/lib.rs") + lib = lib_path.read_text(encoding="utf-8") + lib = replace_once( + lib, + """//! Raw topic proportions are not Euclidean indicators. This crate classifies +//! constructs, admits only log-ratio or logistic-normal coordinates, aggregates +//! plausible-value loadings on a CPU `f64` path, and refuses causal language +//! from temporal precedence, document linkage, event tracking, or prediction. +""", + """//! 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. +""", + "crate claim boundary", + ) + lib = lib.replace("plausible_value_mean", "posterior_draw_point_estimate_mean") + lib = lib.replace( + "recover_loading_from_plausible_values", + "recover_loading_point_estimate_mean", + ) + lib = lib.replace( + "/// Arithmetic mean of plausible-value draws.", + "/// Arithmetic mean of posterior-draw point estimates.", + ) + lib = lib.replace( + "/// Average OLS loadings across posterior indicator draws.", + "/// Average OLS loading point estimates across posterior indicator draws.", + ) + lib_path.write_text(lib, encoding="utf-8") + + test_path = Path("crates/psychometric_core/tests/esem_input_recovery_contract.rs") + tests = test_path.read_text(encoding="utf-8") + tests = tests.replace("plausible_value_mean", "posterior_draw_point_estimate_mean") + tests = tests.replace( + "recover_loading_from_plausible_values", + "recover_loading_point_estimate_mean", + ) + tests = tests.replace( + "plausible_value_mean_recovers_true_loading_under_symmetric_draw_noise", + "posterior_draw_point_estimate_mean_recovers_under_symmetric_draw_noise", + ) + tests = tests.replace("plausible-value loading", "posterior-draw point-estimate loading") + tests = tests.replace("plausible-value RMSE", "posterior-draw point-estimate RMSE") + tests = tests.replace( + "IndicatorKind::AdditiveLogRatio.is_valid_psychometric_input()", + "IndicatorKind::AdditiveLogRatio.is_valid_structural_input()", + ) + tests = tests.replace( + "IndicatorKind::IsometricLogRatio.is_valid_psychometric_input()", + "IndicatorKind::IsometricLogRatio.is_valid_structural_input()", + ) + tests = tests.replace( + "IndicatorKind::LogisticNormal.is_valid_psychometric_input()", + "IndicatorKind::LogisticNormal.is_valid_structural_input()", + ) + tests = tests.replace( + "IndicatorKind::RawProportion.is_valid_psychometric_input()", + "IndicatorKind::RawProportion.is_valid_structural_input()", + ) + test_path.write_text(tests, encoding="utf-8") + + +def update_architecture_and_research() -> None: + """Describe the implemented slice without claiming ESEM or Rubin pooling.""" + architecture_path = Path("ARCHITECTURE.md") + architecture = architecture_path.read_text(encoding="utf-8") + architecture = architecture.replace( + "posterior-aware ESEM/DSEM input gates and CPU `f64` loading recovery", + "posterior-aware structural input gates and CPU `f64` loading point-estimate recovery", + ) + architecture_path.write_text(architecture, encoding="utf-8") + + readme_path = Path("README.md") + readme = readme_path.read_text(encoding="utf-8") + readme = readme.replace( + "crates/psychometric_core", + "crates/psychometric_core # construct/input gates; not a full ESEM/DSEM estimator", + 1, + ) + readme_path.write_text(readme, encoding="utf-8") + + research_path = Path("docs/research/posterior-esem-input-gates.md") + research = research_path.read_text(encoding="utf-8") + research = replace_once( + research, + """3. admit only additive log-ratio, isometric log-ratio, or logistic-normal coordinates; +4. recover a reflective loading by ordinary least squares on a CPU `f64` path; +5. average recovered loadings across posterior indicator draws (plausible values); +""", + """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; +""", + "research scope", + ) + research = replace_once( + research, + """- **Plausible-value loading** is the arithmetic mean of \\(\\hat\\lambda_d\\) across posterior indicator draws (Mislevy, 1991). +""", + """- **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. +""", + "research formula claim", + ) + research = research.replace( + "symmetric plausible-value draw noise cancels in the pooled loading", + "symmetric posterior-draw point-estimate noise cancels in the arithmetic mean", + ) + research_path.write_text(research, encoding="utf-8") + + adr_path = Path("docs/adr/0005-posterior-esem-dsem.md") + adr = adr_path.read_text(encoding="utf-8") + adr = adr.replace( + "CPU `f64` OLS and plausible-value loading recovery", + "CPU `f64` OLS and posterior-draw loading point-estimate averaging (not Rubin variance pooling)", + ) + decision_anchor = ( + "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.\n" + ) + decision_replacement = decision_anchor + ( + "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.\n" + ) + adr = replace_once(adr, decision_anchor, decision_replacement, "ADR current-slice boundary") + adr_path.write_text(adr, encoding="utf-8") + + adr_index_path = Path("docs/adr/README.md") + adr_index = adr_index_path.read_text(encoding="utf-8") + adr_index = adr_index.replace( + "Input gates, plausible-value loading recovery, and causal-refusal are on the active PR", + "Input gates, posterior-draw loading point-estimate averaging, and causal-refusal are on the active PR; Rubin uncertainty pooling remains target work", + ) + adr_index_path.write_text(adr_index, encoding="utf-8") + + +def restore_shared_ledgers() -> None: + """Reapply the PR 49 slice to main-owned conflict-resolved ledgers.""" + changelog_path = Path("CHANGELOG.md") + changelog = changelog_path.read_text(encoding="utf-8") + item = ( + "- `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).\n" + ) + if item not in changelog: + changelog = replace_once(changelog, "### Added\n\n", "### Added\n\n" + item, "CHANGELOG marker") + changelog_path.write_text(changelog, encoding="utf-8") + + trace_path = Path("docs/TRACEABILITY.md") + trace = trace_path.read_text(encoding="utf-8") + trace = replace_once( + trace, + "| 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 |", + "trace psychometric row", + ) + trace_path.write_text(trace, encoding="utf-8") + + validation_path = Path("docs/validation/temporal-event-foundation.md") + validation = validation_path.read_text(encoding="utf-8") + row = ( + "| 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` |\n" + ) + if row not in validation: + marker = ( + "| 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 |\n" + ) + validation = replace_once(validation, marker, marker + row, "validation API row") + validation_path.write_text(validation, encoding="utf-8") + + +update_indicator_contract() +update_posterior_summary_contract() +update_public_api_and_tests() +update_architecture_and_research() +restore_shared_ledgers() From 189f618f7adb1e783683f17a7836f701c25a161c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:43:23 +0900 Subject: [PATCH 04/15] chore(ci): verify PR 49 scientific claim repair --- .../repair-pr49-scientific-claims.yml | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/repair-pr49-scientific-claims.yml diff --git a/.github/workflows/repair-pr49-scientific-claims.yml b/.github/workflows/repair-pr49-scientific-claims.yml new file mode 100644 index 00000000..92cec3dd --- /dev/null +++ b/.github/workflows/repair-pr49-scientific-claims.yml @@ -0,0 +1,75 @@ +name: Repair PR 49 scientific claim boundaries + +on: + pull_request: + types: + - synchronize + - reopened + - ready_for_review + +permissions: + contents: read + +concurrency: + group: repair-tepp-pr-49-scientific-claims + cancel-in-progress: false + +jobs: + repair: + if: >- + github.event.pull_request.number == 49 && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'agent/psychometric-posterior-esem-input' + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + contents: write + steps: + - name: Checkout exact PR branch + uses: actions/checkout@631c942040754b6e095e929c1677c07e10ed4f87 + with: + ref: agent/psychometric-posterior-esem-input + fetch-depth: 0 + persist-credentials: true + + - name: Install pinned Rust toolchain + run: rustup toolchain install 1.97.1 --profile minimal --component clippy --component rustfmt + + - name: Prove geometry and posterior-summary contracts are RED + run: | + set +e + output=$(cargo +1.97.1 test -p psychometric_core --test scientific_claim_boundary_contract 2>&1) + status=$? + set -e + printf '%s\n' "$output" + if [ "$status" -eq 0 ]; then + echo "Expected old API to lack ALR geometry and honest point-estimate boundaries" >&2 + exit 1 + fi + grep -E "is_valid_structural_input|preserves_aitchison_distance|posterior_draw_point_estimate_mean|recover_loading_point_estimate_mean" <<<"$output" + + - name: Apply scientific claim repair + run: | + python3 scripts/repair_pr49_scientific_claims.py + cargo +1.97.1 fmt --all + + - name: Verify focused and workspace contracts + run: | + cargo +1.97.1 fmt --all --check + cargo +1.97.1 test -p psychometric_core --all-features + cargo +1.97.1 clippy -p psychometric_core --all-targets --all-features -- -D warnings + cargo +1.97.1 test --workspace --all-features + python3 scripts/check_workspace_contract.py + python3 scripts/check_docstrings.py + python3 scripts/validate_documentation.py + + - name: Commit verified repair and remove one-shot files + run: | + rm -f .github/workflows/repair-pr49-scientific-claims.yml + rm -f scripts/repair_pr49_scientific_claims.py + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix(psychometric): narrow geometry and posterior claims" + git push origin HEAD:agent/psychometric-posterior-esem-input From 1abeb397c91c65a8ab1f71610225f48a41918bb2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:05:37 +0900 Subject: [PATCH 05/15] fix(ci): normalize repaired documentation whitespace --- .../workflows/repair-pr49-scientific-claims.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repair-pr49-scientific-claims.yml b/.github/workflows/repair-pr49-scientific-claims.yml index 92cec3dd..82828531 100644 --- a/.github/workflows/repair-pr49-scientific-claims.yml +++ b/.github/workflows/repair-pr49-scientific-claims.yml @@ -12,7 +12,7 @@ permissions: concurrency: group: repair-tepp-pr-49-scientific-claims - cancel-in-progress: false + cancel-in-progress: true jobs: repair: @@ -51,6 +51,21 @@ jobs: - name: Apply scientific claim repair run: | python3 scripts/repair_pr49_scientific_claims.py + python3 - <<'PY' + from pathlib import Path + + for path_string in ( + "docs/adr/0005-posterior-esem-dsem.md", + "docs/research/posterior-esem-input.md", + "docs/validation/temporal-event-foundation.md", + ): + path = Path(path_string) + if not path.exists(): + continue + text = path.read_text(encoding="utf-8") + normalized = "\n".join(line.rstrip() for line in text.splitlines()) + "\n" + path.write_text(normalized, encoding="utf-8") + PY cargo +1.97.1 fmt --all - name: Verify focused and workspace contracts From c42a073c728bdb221c614df0393b7469af3a6b9f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:20:07 +0900 Subject: [PATCH 06/15] test(psychometric): require stable extreme plausible-value means --- ...usible_value_numeric_stability_contract.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs 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..f349ea16 --- /dev/null +++ b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs @@ -0,0 +1,25 @@ +//! Plausible-value aggregation must remain finite under valid extreme draws. + +use psychometric_core::{PsychometricError, plausible_value_mean}; + +#[test] +fn scaled_mean_recovers_balanced_extreme_posterior_draws() { + let mean = plausible_value_mean(&[f64::MAX, f64::MAX, -f64::MAX, -f64::MAX]) + .expect("balanced finite draws have a finite mean"); + assert_eq!(mean, 0.0); +} + +#[test] +fn scaled_mean_preserves_an_extreme_constant_draw() { + let mean = plausible_value_mean(&[f64::MAX, f64::MAX]) + .expect("constant finite extreme draws have a finite mean"); + assert_eq!(mean, f64::MAX); +} + +#[test] +fn nonfinite_draws_remain_rejected() { + assert_eq!( + plausible_value_mean(&[1.0, f64::INFINITY]), + Err(PsychometricError::InvalidNumericInput) + ); +} From 24bac68c02cfde9f4520d8f095fda5c2466559b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:20:53 +0900 Subject: [PATCH 07/15] test(psychometric): cover zero-scale plausible-value means --- .../tests/plausible_value_numeric_stability_contract.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs index f349ea16..84cd3e74 100644 --- a/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs +++ b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs @@ -16,6 +16,14 @@ fn scaled_mean_preserves_an_extreme_constant_draw() { assert_eq!(mean, f64::MAX); } +#[test] +fn all_zero_draws_have_an_exact_zero_mean() { + assert_eq!( + plausible_value_mean(&[0.0, 0.0, 0.0]).expect("zero draws"), + 0.0 + ); +} + #[test] fn nonfinite_draws_remain_rejected() { assert_eq!( From 568276e126b16e47423d5a626aa394c951544d7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:21:24 +0900 Subject: [PATCH 08/15] fix(psychometric): stabilize plausible-value aggregation --- crates/psychometric_core/src/plausible.rs | 24 +++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/psychometric_core/src/plausible.rs b/crates/psychometric_core/src/plausible.rs index c6ba9538..557a2ea3 100644 --- a/crates/psychometric_core/src/plausible.rs +++ b/crates/psychometric_core/src/plausible.rs @@ -4,7 +4,11 @@ use crate::error::PsychometricError; use crate::indicator::{IndicatorKind, require_finite, require_valid_indicator}; use crate::loading::recover_reflective_loading; -/// Arithmetic mean of finite plausible-value draws. +/// Scale-normalized compensated mean of finite plausible-value draws. +/// +/// 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 /// @@ -14,14 +18,26 @@ pub fn plausible_value_mean(draws: &[f64]) -> Result { if draws.is_empty() { return Err(PsychometricError::InvalidNumericInput); } - let mut sum = 0.0_f64; + let mut scale = 0.0_f64; for &value in draws { if !value.is_finite() { return Err(PsychometricError::InvalidNumericInput); } - sum += value; + 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(sum / draws.len() as f64) + require_finite((normalized_sum / draws.len() as f64) * scale) } /// Recover a reflective loading by averaging OLS slopes across posterior From 317f4be00cf3c5308a641375436eb71d969d9caf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:31:59 +0900 Subject: [PATCH 09/15] fix(psychometric): align repair precondition with current docs --- crates/psychometric_core/src/plausible.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/psychometric_core/src/plausible.rs b/crates/psychometric_core/src/plausible.rs index 557a2ea3..6197b344 100644 --- a/crates/psychometric_core/src/plausible.rs +++ b/crates/psychometric_core/src/plausible.rs @@ -4,7 +4,7 @@ use crate::error::PsychometricError; use crate::indicator::{IndicatorKind, require_finite, require_valid_indicator}; use crate::loading::recover_reflective_loading; -/// Scale-normalized compensated mean of finite plausible-value draws. +/// Arithmetic mean of finite plausible-value draws. /// /// Scaling by the largest absolute draw prevents valid finite posterior values /// from overflowing during aggregation. Compensated accumulation preserves From c19086a0e151e0bf6b0132fb552aa35611e44c6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 18:42:50 +0900 Subject: [PATCH 10/15] fix(psychometric): align stability tests with honest point estimate API --- ...usible_value_numeric_stability_contract.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs index 84cd3e74..80616535 100644 --- a/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs +++ b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs @@ -1,17 +1,22 @@ -//! Plausible-value aggregation must remain finite under valid extreme draws. +//! Posterior-draw point-estimate aggregation must remain finite under valid extreme draws. -use psychometric_core::{PsychometricError, plausible_value_mean}; +use psychometric_core::{PsychometricError, posterior_draw_point_estimate_mean}; #[test] fn scaled_mean_recovers_balanced_extreme_posterior_draws() { - let mean = plausible_value_mean(&[f64::MAX, f64::MAX, -f64::MAX, -f64::MAX]) - .expect("balanced finite draws have a finite mean"); + let mean = posterior_draw_point_estimate_mean(&[ + f64::MAX, + f64::MAX, + -f64::MAX, + -f64::MAX, + ]) + .expect("balanced finite draws have a finite mean"); assert_eq!(mean, 0.0); } #[test] fn scaled_mean_preserves_an_extreme_constant_draw() { - let mean = plausible_value_mean(&[f64::MAX, f64::MAX]) + let mean = posterior_draw_point_estimate_mean(&[f64::MAX, f64::MAX]) .expect("constant finite extreme draws have a finite mean"); assert_eq!(mean, f64::MAX); } @@ -19,7 +24,7 @@ fn scaled_mean_preserves_an_extreme_constant_draw() { #[test] fn all_zero_draws_have_an_exact_zero_mean() { assert_eq!( - plausible_value_mean(&[0.0, 0.0, 0.0]).expect("zero draws"), + posterior_draw_point_estimate_mean(&[0.0, 0.0, 0.0]).expect("zero draws"), 0.0 ); } @@ -27,7 +32,7 @@ fn all_zero_draws_have_an_exact_zero_mean() { #[test] fn nonfinite_draws_remain_rejected() { assert_eq!( - plausible_value_mean(&[1.0, f64::INFINITY]), + posterior_draw_point_estimate_mean(&[1.0, f64::INFINITY]), Err(PsychometricError::InvalidNumericInput) ); } From f828f2b24340426d7b75045769cbce3243fd133e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 19:03:13 +0900 Subject: [PATCH 11/15] test(psychometric): satisfy strict float comparison lint --- .../plausible_value_numeric_stability_contract.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs index 80616535..d651c31d 100644 --- a/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs +++ b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs @@ -11,22 +11,20 @@ fn scaled_mean_recovers_balanced_extreme_posterior_draws() { -f64::MAX, ]) .expect("balanced finite draws have a finite mean"); - assert_eq!(mean, 0.0); + 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, f64::MAX); + assert_eq!(mean.to_bits(), f64::MAX.to_bits()); } #[test] fn all_zero_draws_have_an_exact_zero_mean() { - assert_eq!( - posterior_draw_point_estimate_mean(&[0.0, 0.0, 0.0]).expect("zero draws"), - 0.0 - ); + let mean = posterior_draw_point_estimate_mean(&[0.0, 0.0, 0.0]).expect("zero draws"); + assert!(mean.abs() < f64::EPSILON); } #[test] From b1004197ff9b9146de7eec8a585ed25f70aefa25 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:20:15 +0000 Subject: [PATCH 12/15] fix(psychometric): narrow geometry and posterior claims --- .../repair-pr49-scientific-claims.yml | 90 ------ ARCHITECTURE.md | 2 +- CHANGELOG.md | 1 + README.md | 2 +- crates/psychometric_core/src/indicator.rs | 22 +- crates/psychometric_core/src/lib.rs | 17 +- crates/psychometric_core/src/plausible.rs | 29 +- .../tests/esem_input_recovery_contract.rs | 30 +- ...usible_value_numeric_stability_contract.rs | 9 +- docs/TRACEABILITY.md | 2 +- docs/adr/0005-posterior-esem-dsem.md | 7 +- docs/adr/README.md | 2 +- docs/research/posterior-esem-input-gates.md | 10 +- docs/validation/temporal-event-foundation.md | 5 +- scripts/repair_pr49_scientific_claims.py | 294 ------------------ 15 files changed, 79 insertions(+), 443 deletions(-) delete mode 100644 .github/workflows/repair-pr49-scientific-claims.yml delete mode 100644 scripts/repair_pr49_scientific_claims.py diff --git a/.github/workflows/repair-pr49-scientific-claims.yml b/.github/workflows/repair-pr49-scientific-claims.yml deleted file mode 100644 index 82828531..00000000 --- a/.github/workflows/repair-pr49-scientific-claims.yml +++ /dev/null @@ -1,90 +0,0 @@ -name: Repair PR 49 scientific claim boundaries - -on: - pull_request: - types: - - synchronize - - reopened - - ready_for_review - -permissions: - contents: read - -concurrency: - group: repair-tepp-pr-49-scientific-claims - cancel-in-progress: true - -jobs: - repair: - if: >- - github.event.pull_request.number == 49 && - github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.head.ref == 'agent/psychometric-posterior-esem-input' - runs-on: ubuntu-latest - timeout-minutes: 35 - permissions: - contents: write - steps: - - name: Checkout exact PR branch - uses: actions/checkout@631c942040754b6e095e929c1677c07e10ed4f87 - with: - ref: agent/psychometric-posterior-esem-input - fetch-depth: 0 - persist-credentials: true - - - name: Install pinned Rust toolchain - run: rustup toolchain install 1.97.1 --profile minimal --component clippy --component rustfmt - - - name: Prove geometry and posterior-summary contracts are RED - run: | - set +e - output=$(cargo +1.97.1 test -p psychometric_core --test scientific_claim_boundary_contract 2>&1) - status=$? - set -e - printf '%s\n' "$output" - if [ "$status" -eq 0 ]; then - echo "Expected old API to lack ALR geometry and honest point-estimate boundaries" >&2 - exit 1 - fi - grep -E "is_valid_structural_input|preserves_aitchison_distance|posterior_draw_point_estimate_mean|recover_loading_point_estimate_mean" <<<"$output" - - - name: Apply scientific claim repair - run: | - python3 scripts/repair_pr49_scientific_claims.py - python3 - <<'PY' - from pathlib import Path - - for path_string in ( - "docs/adr/0005-posterior-esem-dsem.md", - "docs/research/posterior-esem-input.md", - "docs/validation/temporal-event-foundation.md", - ): - path = Path(path_string) - if not path.exists(): - continue - text = path.read_text(encoding="utf-8") - normalized = "\n".join(line.rstrip() for line in text.splitlines()) + "\n" - path.write_text(normalized, encoding="utf-8") - PY - cargo +1.97.1 fmt --all - - - name: Verify focused and workspace contracts - run: | - cargo +1.97.1 fmt --all --check - cargo +1.97.1 test -p psychometric_core --all-features - cargo +1.97.1 clippy -p psychometric_core --all-targets --all-features -- -D warnings - cargo +1.97.1 test --workspace --all-features - python3 scripts/check_workspace_contract.py - python3 scripts/check_docstrings.py - python3 scripts/validate_documentation.py - - - name: Commit verified repair and remove one-shot files - run: | - rm -f .github/workflows/repair-pr49-scientific-claims.yml - rm -f scripts/repair_pr49_scientific_claims.py - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix(psychometric): narrow geometry and posterior claims" - git push origin HEAD:agent/psychometric-posterior-esem-input diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 18115747..c4e98ea4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -61,7 +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 ESEM/DSEM input gates and CPU `f64` loading recovery | +| `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 c1cc6e87..3969c5e7 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). - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). - `persistence_postgres` concurrent document-write stress: atomic revise `DO` block that requires exactly one open `system_to` close, SQLSTATE mapping onto `ConcurrentWriteConflict` / `DuplicateDocumentRecord`, and live multi-session insert/revise/append-only proofs. No new migration number. - `tepp_api` naruon HTTP interchange: versioned `https` POST contracts for analysis-run create and modular export authorization that refuse table-access URLs, review/Copilot credential headers, reserved standard-header redefinition, principal-only export idempotency keys, and lexical inference claims (ADR 0011). diff --git a/README.md b/README.md index de411879..bbf041ad 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api -crates/psychometric_core +crates/psychometric_core # construct/input gates; not a full ESEM/DSEM estimator ``` ## Local verification diff --git a/crates/psychometric_core/src/indicator.rs b/crates/psychometric_core/src/indicator.rs index 51568f22..7688c8b8 100644 --- a/crates/psychometric_core/src/indicator.rs +++ b/crates/psychometric_core/src/indicator.rs @@ -1,4 +1,4 @@ -//! Valid psychometric indicator coordinates. +//! Valid structural indicator coordinates and compositional-geometry claims. use crate::error::PsychometricError; @@ -28,11 +28,21 @@ impl IndicatorKind { } } - /// Return whether the kind is a valid Euclidean psychometric input. + /// 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_psychometric_input(self) -> bool { + 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. @@ -42,7 +52,7 @@ impl IndicatorKind { /// Returns [`PsychometricError::RawProportionForbidden`] for /// [`IndicatorKind::RawProportion`]. pub fn require_valid_indicator(kind: IndicatorKind) -> Result<(), PsychometricError> { - if kind.is_valid_psychometric_input() { + if kind.is_valid_structural_input() { Ok(()) } else { Err(PsychometricError::RawProportionForbidden) @@ -51,6 +61,10 @@ pub fn require_valid_indicator(kind: IndicatorKind) -> Result<(), PsychometricEr /// 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 diff --git a/crates/psychometric_core/src/lib.rs b/crates/psychometric_core/src/lib.rs index 1587ef1f..3476e0b9 100644 --- a/crates/psychometric_core/src/lib.rs +++ b/crates/psychometric_core/src/lib.rs @@ -3,10 +3,11 @@ #![allow(clippy::cast_precision_loss)] //! Posterior-aware psychometric input gates for ESEM/DSEM. //! -//! Raw topic proportions are not Euclidean indicators. This crate classifies -//! constructs, admits only log-ratio or logistic-normal coordinates, aggregates -//! plausible-value loadings on a CPU `f64` path, and refuses causal language -//! from temporal precedence, document linkage, event tracking, or prediction. +//! 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; @@ -37,7 +38,7 @@ pub use indicator::require_valid_indicator; pub use loading::ordinary_least_squares_slope; /// Recover one reflective loading. pub use loading::recover_reflective_loading; -/// Arithmetic mean of plausible-value draws. -pub use plausible::plausible_value_mean; -/// Average OLS loadings across posterior indicator draws. -pub use plausible::recover_loading_from_plausible_values; +/// 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/plausible.rs b/crates/psychometric_core/src/plausible.rs index 6197b344..d24fae41 100644 --- a/crates/psychometric_core/src/plausible.rs +++ b/crates/psychometric_core/src/plausible.rs @@ -1,10 +1,13 @@ -//! Plausible-value aggregation of posterior structural draws. +//! 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 plausible-value draws. +/// 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 @@ -14,7 +17,7 @@ use crate::loading::recover_reflective_loading; /// /// Returns [`PsychometricError::InvalidNumericInput`] when `draws` is empty or /// contains a non-finite value. -pub fn plausible_value_mean(draws: &[f64]) -> Result { +pub fn posterior_draw_point_estimate_mean(draws: &[f64]) -> Result { if draws.is_empty() { return Err(PsychometricError::InvalidNumericInput); } @@ -40,14 +43,18 @@ pub fn plausible_value_mean(draws: &[f64]) -> Result { require_finite((normalized_sum / draws.len() as f64) * scale) } -/// Recover a reflective loading by averaging OLS slopes across posterior -/// indicator draws (Rubin-style plausible values). +/// 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_from_plausible_values( +pub fn recover_loading_point_estimate_mean( factor_scores: &[f64], indicator_draws: &[Vec], kind: IndicatorKind, @@ -60,21 +67,21 @@ pub fn recover_loading_from_plausible_values( for draw in indicator_draws { recovered.push(recover_reflective_loading(factor_scores, draw, kind)?); } - plausible_value_mean(&recovered) + posterior_draw_point_estimate_mean(&recovered) } #[cfg(test)] mod tests { - use super::{plausible_value_mean, recover_loading_from_plausible_values}; + 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_draws_and_nonfinite_mean_fail_closed() { - let mean = plausible_value_mean(&[1.0, 3.0]).expect("mean"); + 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_from_plausible_values( + recover_loading_point_estimate_mean( &[0.0, 1.0], &[vec![0.0, f64::NAN]], IndicatorKind::AdditiveLogRatio diff --git a/crates/psychometric_core/tests/esem_input_recovery_contract.rs b/crates/psychometric_core/tests/esem_input_recovery_contract.rs index 414c0077..de3fddc8 100644 --- a/crates/psychometric_core/tests/esem_input_recovery_contract.rs +++ b/crates/psychometric_core/tests/esem_input_recovery_contract.rs @@ -4,7 +4,7 @@ use psychometric_core::{ CausalHeuristic, ConstructClass, IndicatorKind, PsychometricError, claim_causal_effect, compare_latent_means, interpret_as_reflective, ordinary_least_squares_slope, - pearson_correlation, plausible_value_mean, recover_loading_from_plausible_values, + pearson_correlation, posterior_draw_point_estimate_mean, recover_loading_point_estimate_mean, recover_reflective_loading, require_valid_indicator, }; @@ -46,7 +46,7 @@ fn known_loading_recovers_through_ols_with_computed_rmse() { } #[test] -fn plausible_value_mean_recovers_true_loading_under_symmetric_draw_noise() { +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); @@ -60,16 +60,16 @@ fn plausible_value_mean_recovers_true_loading_under_symmetric_draw_noise() { ); } - let pooled = recover_loading_from_plausible_values( + let pooled = recover_loading_point_estimate_mean( &factor_scores, &indicator_draws, IndicatorKind::LogisticNormal, ) - .expect("plausible-value loading"); + .expect("posterior-draw point-estimate loading"); let pooled_error = rmse(&[true_loading], &[pooled]); assert!( pooled_error < 1e-12, - "symmetric plausible-value RMSE {pooled_error} should cancel" + "symmetric posterior-draw point-estimate RMSE {pooled_error} should cancel" ); let single = recover_reflective_loading( @@ -100,7 +100,7 @@ fn raw_proportions_and_invalid_numeric_inputs_fail_closed() { Err(PsychometricError::RawProportionForbidden) ); assert_eq!( - recover_loading_from_plausible_values( + recover_loading_point_estimate_mean( &[1.0, 2.0], &[vec![0.5, 0.5]], IndicatorKind::RawProportion @@ -133,19 +133,19 @@ fn raw_proportions_and_invalid_numeric_inputs_fail_closed() { Err(PsychometricError::SingularDesign) ); assert_eq!( - plausible_value_mean(&[]), + posterior_draw_point_estimate_mean(&[]), Err(PsychometricError::InvalidNumericInput) ); assert_eq!( - plausible_value_mean(&[1.0, f64::INFINITY]), + posterior_draw_point_estimate_mean(&[1.0, f64::INFINITY]), Err(PsychometricError::InvalidNumericInput) ); assert_eq!( - recover_loading_from_plausible_values(&[1.0, 2.0], &[], IndicatorKind::AdditiveLogRatio), + recover_loading_point_estimate_mean(&[1.0, 2.0], &[], IndicatorKind::AdditiveLogRatio), Err(PsychometricError::InvalidNumericInput) ); assert_eq!( - recover_loading_from_plausible_values( + recover_loading_point_estimate_mean( &[1.0, 2.0], &[vec![1.0]], IndicatorKind::AdditiveLogRatio @@ -213,10 +213,10 @@ fn construct_class_and_causal_heuristics_refuse_overclaim() { assert!(!heuristic.as_str().is_empty()); } - assert!(IndicatorKind::AdditiveLogRatio.is_valid_psychometric_input()); - assert!(IndicatorKind::IsometricLogRatio.is_valid_psychometric_input()); - assert!(IndicatorKind::LogisticNormal.is_valid_psychometric_input()); - assert!(!IndicatorKind::RawProportion.is_valid_psychometric_input()); + 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"); @@ -233,7 +233,7 @@ fn finite_alr_correlation_and_error_messages_are_stable() { let slope = ordinary_least_squares_slope(&left, &right).expect("slope"); assert!((slope - 2.0).abs() < 1e-12); - let mean = plausible_value_mean(&[0.7, 0.8, 0.9]).expect("mean"); + let mean = posterior_draw_point_estimate_mean(&[0.7, 0.8, 0.9]).expect("mean"); assert!((mean - 0.8).abs() < 1e-15); assert_eq!( diff --git a/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs index d651c31d..8d86a158 100644 --- a/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs +++ b/crates/psychometric_core/tests/plausible_value_numeric_stability_contract.rs @@ -4,13 +4,8 @@ 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"); + 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); } diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index c29d9743..f012e93a 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 | future `interpretation_gateway` | accepted-target | diff --git a/docs/adr/0005-posterior-esem-dsem.md b/docs/adr/0005-posterior-esem-dsem.md index 3cef4c6f..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:** partial — construct classification, valid log-ratio/logistic-normal indicator gates, CPU `f64` OLS and plausible-value loading recovery, 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 +**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/README.md b/docs/adr/README.md index a85efef9..18c019d9 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 | partial | Input gates, plausible-value loading recovery, and causal-refusal are on the active PR; full ESEM/DSEM estimator remains accepted-target. | +| [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/research/posterior-esem-input-gates.md b/docs/research/posterior-esem-input-gates.md index cfc29848..b431aae1 100644 --- a/docs/research/posterior-esem-input-gates.md +++ b/docs/research/posterior-esem-input-gates.md @@ -6,9 +6,9 @@ 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 only additive log-ratio, isometric log-ratio, or logistic-normal coordinates; -4. recover a reflective loading by ordinary least squares on a CPU `f64` path; -5. average recovered loadings across posterior indicator draws (plausible values); +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. @@ -31,12 +31,12 @@ Holland, P. W. (1986). Statistics and causal inference. *Journal of the American ## 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. -- **Plausible-value loading** is the arithmetic mean of \(\hat\lambda_d\) across posterior indicator draws (Mislevy, 1991). +- **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 plausible-value draw noise cancels in the pooled loading and has smaller computed RMSE than a single draw; +- 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/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 295fbae0..ac659ebc 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-12 **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` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 | diff --git a/scripts/repair_pr49_scientific_claims.py b/scripts/repair_pr49_scientific_claims.py deleted file mode 100644 index aa21fd2b..00000000 --- a/scripts/repair_pr49_scientific_claims.py +++ /dev/null @@ -1,294 +0,0 @@ -"""Apply PR 49 compositional-geometry and posterior-summary claim repairs.""" - -from pathlib import Path - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - """Replace exactly one fragment or fail closed.""" - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one target, found {count}") - return text.replace(old, new, 1) - - -def update_indicator_contract() -> None: - """Distinguish valid structural coordinates from Aitchison isometries.""" - path = Path("crates/psychometric_core/src/indicator.rs") - text = path.read_text(encoding="utf-8") - text = replace_once( - text, - "//! Valid psychometric indicator coordinates.\n", - "//! Valid structural indicator coordinates and compositional-geometry claims.\n", - "indicator module docs", - ) - old_block = """ /// Return whether the kind is a valid Euclidean psychometric input. - #[must_use] - pub const fn is_valid_psychometric_input(self) -> bool { - !matches!(self, Self::RawProportion) - } -""" - new_block = """ /// 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) - } -""" - text = replace_once(text, old_block, new_block, "indicator geometry methods") - text = text.replace("kind.is_valid_psychometric_input()", "kind.is_valid_structural_input()") - text = replace_once( - text, - "/// Pearson product-moment correlation on already-mapped coordinates.\n", - """/// 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. -""", - "Pearson claim boundary", - ) - path.write_text(text, encoding="utf-8") - - -def update_posterior_summary_contract() -> None: - """Rename point-estimate averages so they cannot imply Rubin pooling.""" - path = Path("crates/psychometric_core/src/plausible.rs") - text = path.read_text(encoding="utf-8") - text = replace_once( - text, - "//! Plausible-value aggregation of posterior structural draws.\n", - "//! Point-estimate aggregation across posterior structural draws.\n", - "posterior module docs", - ) - text = text.replace("plausible_value_mean", "posterior_draw_point_estimate_mean") - text = text.replace( - "recover_loading_from_plausible_values", - "recover_loading_point_estimate_mean", - ) - text = replace_once( - text, - "/// Arithmetic mean of finite plausible-value draws.\n", - """/// 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. -""", - "point-estimate mean docs", - ) - text = replace_once( - text, - """/// Recover a reflective loading by averaging OLS slopes across posterior -/// indicator draws (Rubin-style plausible values). -""", - """/// 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. -""", - "loading aggregation docs", - ) - text = text.replace("plausible-value loading", "posterior-draw loading point estimate") - text = text.replace("mean_of_two_draws", "mean_of_two_point_estimates") - path.write_text(text, encoding="utf-8") - - -def update_public_api_and_tests() -> None: - """Align exports and recovery tests with the narrower scientific claims.""" - lib_path = Path("crates/psychometric_core/src/lib.rs") - lib = lib_path.read_text(encoding="utf-8") - lib = replace_once( - lib, - """//! Raw topic proportions are not Euclidean indicators. This crate classifies -//! constructs, admits only log-ratio or logistic-normal coordinates, aggregates -//! plausible-value loadings on a CPU `f64` path, and refuses causal language -//! from temporal precedence, document linkage, event tracking, or prediction. -""", - """//! 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. -""", - "crate claim boundary", - ) - lib = lib.replace("plausible_value_mean", "posterior_draw_point_estimate_mean") - lib = lib.replace( - "recover_loading_from_plausible_values", - "recover_loading_point_estimate_mean", - ) - lib = lib.replace( - "/// Arithmetic mean of plausible-value draws.", - "/// Arithmetic mean of posterior-draw point estimates.", - ) - lib = lib.replace( - "/// Average OLS loadings across posterior indicator draws.", - "/// Average OLS loading point estimates across posterior indicator draws.", - ) - lib_path.write_text(lib, encoding="utf-8") - - test_path = Path("crates/psychometric_core/tests/esem_input_recovery_contract.rs") - tests = test_path.read_text(encoding="utf-8") - tests = tests.replace("plausible_value_mean", "posterior_draw_point_estimate_mean") - tests = tests.replace( - "recover_loading_from_plausible_values", - "recover_loading_point_estimate_mean", - ) - tests = tests.replace( - "plausible_value_mean_recovers_true_loading_under_symmetric_draw_noise", - "posterior_draw_point_estimate_mean_recovers_under_symmetric_draw_noise", - ) - tests = tests.replace("plausible-value loading", "posterior-draw point-estimate loading") - tests = tests.replace("plausible-value RMSE", "posterior-draw point-estimate RMSE") - tests = tests.replace( - "IndicatorKind::AdditiveLogRatio.is_valid_psychometric_input()", - "IndicatorKind::AdditiveLogRatio.is_valid_structural_input()", - ) - tests = tests.replace( - "IndicatorKind::IsometricLogRatio.is_valid_psychometric_input()", - "IndicatorKind::IsometricLogRatio.is_valid_structural_input()", - ) - tests = tests.replace( - "IndicatorKind::LogisticNormal.is_valid_psychometric_input()", - "IndicatorKind::LogisticNormal.is_valid_structural_input()", - ) - tests = tests.replace( - "IndicatorKind::RawProportion.is_valid_psychometric_input()", - "IndicatorKind::RawProportion.is_valid_structural_input()", - ) - test_path.write_text(tests, encoding="utf-8") - - -def update_architecture_and_research() -> None: - """Describe the implemented slice without claiming ESEM or Rubin pooling.""" - architecture_path = Path("ARCHITECTURE.md") - architecture = architecture_path.read_text(encoding="utf-8") - architecture = architecture.replace( - "posterior-aware ESEM/DSEM input gates and CPU `f64` loading recovery", - "posterior-aware structural input gates and CPU `f64` loading point-estimate recovery", - ) - architecture_path.write_text(architecture, encoding="utf-8") - - readme_path = Path("README.md") - readme = readme_path.read_text(encoding="utf-8") - readme = readme.replace( - "crates/psychometric_core", - "crates/psychometric_core # construct/input gates; not a full ESEM/DSEM estimator", - 1, - ) - readme_path.write_text(readme, encoding="utf-8") - - research_path = Path("docs/research/posterior-esem-input-gates.md") - research = research_path.read_text(encoding="utf-8") - research = replace_once( - research, - """3. admit only additive log-ratio, isometric log-ratio, or logistic-normal coordinates; -4. recover a reflective loading by ordinary least squares on a CPU `f64` path; -5. average recovered loadings across posterior indicator draws (plausible values); -""", - """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; -""", - "research scope", - ) - research = replace_once( - research, - """- **Plausible-value loading** is the arithmetic mean of \\(\\hat\\lambda_d\\) across posterior indicator draws (Mislevy, 1991). -""", - """- **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. -""", - "research formula claim", - ) - research = research.replace( - "symmetric plausible-value draw noise cancels in the pooled loading", - "symmetric posterior-draw point-estimate noise cancels in the arithmetic mean", - ) - research_path.write_text(research, encoding="utf-8") - - adr_path = Path("docs/adr/0005-posterior-esem-dsem.md") - adr = adr_path.read_text(encoding="utf-8") - adr = adr.replace( - "CPU `f64` OLS and plausible-value loading recovery", - "CPU `f64` OLS and posterior-draw loading point-estimate averaging (not Rubin variance pooling)", - ) - decision_anchor = ( - "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.\n" - ) - decision_replacement = decision_anchor + ( - "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.\n" - ) - adr = replace_once(adr, decision_anchor, decision_replacement, "ADR current-slice boundary") - adr_path.write_text(adr, encoding="utf-8") - - adr_index_path = Path("docs/adr/README.md") - adr_index = adr_index_path.read_text(encoding="utf-8") - adr_index = adr_index.replace( - "Input gates, plausible-value loading recovery, and causal-refusal are on the active PR", - "Input gates, posterior-draw loading point-estimate averaging, and causal-refusal are on the active PR; Rubin uncertainty pooling remains target work", - ) - adr_index_path.write_text(adr_index, encoding="utf-8") - - -def restore_shared_ledgers() -> None: - """Reapply the PR 49 slice to main-owned conflict-resolved ledgers.""" - changelog_path = Path("CHANGELOG.md") - changelog = changelog_path.read_text(encoding="utf-8") - item = ( - "- `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).\n" - ) - if item not in changelog: - changelog = replace_once(changelog, "### Added\n\n", "### Added\n\n" + item, "CHANGELOG marker") - changelog_path.write_text(changelog, encoding="utf-8") - - trace_path = Path("docs/TRACEABILITY.md") - trace = trace_path.read_text(encoding="utf-8") - trace = replace_once( - trace, - "| 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 |", - "trace psychometric row", - ) - trace_path.write_text(trace, encoding="utf-8") - - validation_path = Path("docs/validation/temporal-event-foundation.md") - validation = validation_path.read_text(encoding="utf-8") - row = ( - "| 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` |\n" - ) - if row not in validation: - marker = ( - "| 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 |\n" - ) - validation = replace_once(validation, marker, marker + row, "validation API row") - validation_path.write_text(validation, encoding="utf-8") - - -update_indicator_contract() -update_posterior_summary_contract() -update_public_api_and_tests() -update_architecture_and_research() -restore_shared_ledgers() From f60748dbbdc922abcf6c2621801589ba855f8af2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:20:33 +0900 Subject: [PATCH 13/15] test(psychometric): close indicator branch coverage --- crates/psychometric_core/src/indicator.rs | 20 +++++++++++++++++++ .../tests/esem_input_recovery_contract.rs | 16 +++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/crates/psychometric_core/src/indicator.rs b/crates/psychometric_core/src/indicator.rs index 7688c8b8..f7a471a4 100644 --- a/crates/psychometric_core/src/indicator.rs +++ b/crates/psychometric_core/src/indicator.rs @@ -133,10 +133,30 @@ mod tests { #[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(&[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], diff --git a/crates/psychometric_core/tests/esem_input_recovery_contract.rs b/crates/psychometric_core/tests/esem_input_recovery_contract.rs index de3fddc8..3e19b547 100644 --- a/crates/psychometric_core/tests/esem_input_recovery_contract.rs +++ b/crates/psychometric_core/tests/esem_input_recovery_contract.rs @@ -128,10 +128,26 @@ fn raw_proportions_and_invalid_numeric_inputs_fail_closed() { 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) From 88ed0422fd7f74b15d045926b346701843980359 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:31:07 +0900 Subject: [PATCH 14/15] test(psychometric): cover estimator edge branches --- crates/psychometric_core/src/indicator.rs | 16 ++++++++++++++++ crates/psychometric_core/src/loading.rs | 12 ++++++++++++ .../tests/esem_input_recovery_contract.rs | 4 ++++ 3 files changed, 32 insertions(+) diff --git a/crates/psychometric_core/src/indicator.rs b/crates/psychometric_core/src/indicator.rs index 7688c8b8..897f2e9b 100644 --- a/crates/psychometric_core/src/indicator.rs +++ b/crates/psychometric_core/src/indicator.rs @@ -133,10 +133,26 @@ mod tests { #[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, 2.0], + &[1.0, f64::NAN], + IndicatorKind::AdditiveLogRatio + ), + Err(PsychometricError::InvalidNumericInput) + ); assert_eq!( pearson_correlation( &[0.0, f64::MAX], diff --git a/crates/psychometric_core/src/loading.rs b/crates/psychometric_core/src/loading.rs index a45dd328..75c45f3f 100644 --- a/crates/psychometric_core/src/loading.rs +++ b/crates/psychometric_core/src/loading.rs @@ -60,5 +60,17 @@ mod tests { 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/tests/esem_input_recovery_contract.rs b/crates/psychometric_core/tests/esem_input_recovery_contract.rs index de3fddc8..41078ecb 100644 --- a/crates/psychometric_core/tests/esem_input_recovery_contract.rs +++ b/crates/psychometric_core/tests/esem_input_recovery_contract.rs @@ -132,6 +132,10 @@ fn raw_proportions_and_invalid_numeric_inputs_fail_closed() { 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!( posterior_draw_point_estimate_mean(&[]), Err(PsychometricError::InvalidNumericInput) From b713010705cb09c54d19cdbdd5c7ac89d5f854aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 22:35:53 +0900 Subject: [PATCH 15/15] test(psychometric): repair merged coverage test --- crates/psychometric_core/tests/esem_input_recovery_contract.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/psychometric_core/tests/esem_input_recovery_contract.rs b/crates/psychometric_core/tests/esem_input_recovery_contract.rs index 815472aa..3e19b547 100644 --- a/crates/psychometric_core/tests/esem_input_recovery_contract.rs +++ b/crates/psychometric_core/tests/esem_input_recovery_contract.rs @@ -148,6 +148,7 @@ fn raw_proportions_and_invalid_numeric_inputs_fail_closed() { ), Err(PsychometricError::InvalidNumericInput) ); + assert_eq!( posterior_draw_point_estimate_mean(&[]), Err(PsychometricError::InvalidNumericInput) );