diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe514db6..78c33da18 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 | +| `measurement_invariance` | configural/metric/scalar status; loading RMSE; no shared meaning from configural only | 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 0f83a9137..078c70ff0 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 +- `measurement_invariance` explicit invariance status: configural structure cannot license shared metric meaning; metric and scalar status may; recovered group loadings match known truth with lower computed RMSE than a crossed-language collapse. - `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). - `persistence_postgres` audit-event SQL contracts: append-only insert that refuses empty, oversized, or hostile `action_code` values before SQL is rendered. diff --git a/Cargo.lock b/Cargo.lock index 372a55f43..57d90d815 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -697,6 +697,10 @@ dependencies = [ "digest", ] +[[package]] +name = "measurement_invariance" +version = "0.1.0" + [[package]] name = "membership_core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 925659406..937649522 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/measurement_invariance", ] default-members = [ "crates/evidence_core", @@ -23,6 +24,7 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/measurement_invariance", ] [workspace.package] diff --git a/README.md b/README.md index ae74015d3..9c1a9c461 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ 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 +The eleven bounded crates compile independently but intentionally expose no placeholder production APIs. Domain behavior begins in Task 2 with immutable evidence identifiers and source records. @@ -22,6 +22,7 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api +crates/measurement_invariance ``` ## Local verification diff --git a/crates/measurement_invariance/Cargo.toml b/crates/measurement_invariance/Cargo.toml new file mode 100644 index 000000000..95c6e157a --- /dev/null +++ b/crates/measurement_invariance/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "measurement_invariance" +description = "Explicit invariance status gates and group-loading RMSE." +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 + +[lints] +workspace = true diff --git a/crates/measurement_invariance/src/error.rs b/crates/measurement_invariance/src/error.rs new file mode 100644 index 000000000..4fa37f538 --- /dev/null +++ b/crates/measurement_invariance/src/error.rs @@ -0,0 +1,53 @@ +//! Fail-closed measurement-invariance errors. + +use std::fmt; + +/// A fail-closed measurement-invariance error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum InvarianceError { + /// A weaker invariance status was treated as shared metric meaning. + InvarianceTooWeakForSharedMeaning, + /// An unknown invariance-status wire name was supplied. + UnknownInvarianceLevel, + /// Loading slices were empty, length-mismatched, or non-finite. + InvalidLoadingPayload, +} + +impl fmt::Display for InvarianceError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::InvarianceTooWeakForSharedMeaning => "invariance is too weak for shared meaning", + Self::UnknownInvarianceLevel => "unknown invariance level", + Self::InvalidLoadingPayload => "invalid invariance loading payload", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for InvarianceError {} + +#[cfg(test)] +mod tests { + use super::InvarianceError; + + #[test] + fn error_messages_are_stable() { + for (error, message) in [ + ( + InvarianceError::InvarianceTooWeakForSharedMeaning, + "invariance is too weak for shared meaning", + ), + ( + InvarianceError::UnknownInvarianceLevel, + "unknown invariance level", + ), + ( + InvarianceError::InvalidLoadingPayload, + "invalid invariance loading payload", + ), + ] { + assert_eq!(error.to_string(), message); + } + } +} diff --git a/crates/measurement_invariance/src/lib.rs b/crates/measurement_invariance/src/lib.rs new file mode 100644 index 000000000..b578be7f0 --- /dev/null +++ b/crates/measurement_invariance/src/lib.rs @@ -0,0 +1,23 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Explicit invariance status and group-loading recovery for shared meaning. +//! +//! Configural structure alone cannot license shared metric meaning. Metric and +//! scalar status may; recovery reports computed loading RMSE against known +//! truth (ADR 0004/0005). + +mod error; +mod loading; +mod status; + +/// Fail-closed measurement-invariance errors. +pub use error::InvarianceError; +/// One group-specific loading. +pub use loading::GroupLoading; +/// RMSE of recovered loadings against known truth. +pub use loading::loading_root_mean_square_error; +/// Established invariance status. +pub use status::InvarianceLevel; +/// Refuse to treat a weaker status as shared meaning. +pub use status::refuse_noninvariant_as_shared_meaning; diff --git a/crates/measurement_invariance/src/loading.rs b/crates/measurement_invariance/src/loading.rs new file mode 100644 index 000000000..57142f4db --- /dev/null +++ b/crates/measurement_invariance/src/loading.rs @@ -0,0 +1,87 @@ +//! Known-truth RMSE for multi-group loadings. + +use crate::InvarianceError; + +/// One group-specific loading used for invariance recovery. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GroupLoading { + group_index: u32, + loading: f64, +} + +impl GroupLoading { + /// Construct a finite group loading. + /// + /// Non-finite values are rejected later by + /// [`loading_root_mean_square_error`]; this constructor keeps the record + /// transparent so tests can compute the same residual. + #[must_use] + pub const fn new(group_index: u32, loading: f64) -> Self { + Self { + group_index, + loading, + } + } + + /// Return the group index. + #[must_use] + pub const fn group_index(self) -> u32 { + self.group_index + } + + /// Return the loading value. + #[must_use] + pub const fn loading(self) -> f64 { + self.loading + } +} + +/// RMSE of recovered loadings against known-truth loadings. +/// +/// # Errors +/// +/// Returns [`InvarianceError::InvalidLoadingPayload`] when either slice is +/// empty, the lengths differ, a group index mismatches, or a loading is +/// non-finite. +pub fn loading_root_mean_square_error( + truth: &[GroupLoading], + decided: &[GroupLoading], +) -> Result { + if truth.is_empty() || truth.len() != decided.len() { + return Err(InvarianceError::InvalidLoadingPayload); + } + let mut sum_squares = 0.0_f64; + for (truth_row, decided_row) in truth.iter().zip(decided) { + if truth_row.group_index() != decided_row.group_index() + || !truth_row.loading().is_finite() + || !decided_row.loading().is_finite() + { + return Err(InvarianceError::InvalidLoadingPayload); + } + let residual = decided_row.loading() - truth_row.loading(); + sum_squares += residual * residual; + } + Ok((sum_squares / truth.len() as f64).sqrt()) +} + +#[cfg(test)] +mod tests { + use super::{GroupLoading, loading_root_mean_square_error}; + use crate::InvarianceError; + + #[test] + fn mismatched_groups_and_nan_fail_closed() { + let truth = [GroupLoading::new(0, 0.5)]; + let other_group = [GroupLoading::new(1, 0.5)]; + assert_eq!( + loading_root_mean_square_error(&truth, &other_group), + Err(InvarianceError::InvalidLoadingPayload) + ); + let nan = [GroupLoading::new(0, f64::NAN)]; + assert_eq!( + loading_root_mean_square_error(&truth, &nan), + Err(InvarianceError::InvalidLoadingPayload) + ); + assert_eq!(GroupLoading::new(2, 0.1).group_index(), 2); + } +} diff --git a/crates/measurement_invariance/src/status.rs b/crates/measurement_invariance/src/status.rs new file mode 100644 index 000000000..e0e577dd2 --- /dev/null +++ b/crates/measurement_invariance/src/status.rs @@ -0,0 +1,86 @@ +//! Explicit invariance status that may or may not license shared meaning. + +use crate::InvarianceError; + +/// Established invariance status for a multi-group comparison. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InvarianceLevel { + /// Same factor structure only; loadings are not comparable. + Configural, + /// Equal loadings; factor variances/means remain group-specific. + Metric, + /// Equal loadings and intercepts. + Scalar, +} + +impl InvarianceLevel { + /// Stable wire name. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Configural => "configural", + Self::Metric => "metric", + Self::Scalar => "scalar", + } + } + + /// Parse a stable wire invariance-status name. + /// + /// # Errors + /// + /// Returns [`InvarianceError::UnknownInvarianceLevel`] for unrecognized names. + pub fn from_wire_name(name: &str) -> Result { + match name { + "configural" => Ok(Self::Configural), + "metric" => Ok(Self::Metric), + "scalar" => Ok(Self::Scalar), + _ => Err(InvarianceError::UnknownInvarianceLevel), + } + } + + /// Return whether this status licenses shared metric meaning. + #[must_use] + pub const fn licenses_shared_meaning(self) -> bool { + matches!(self, Self::Metric | Self::Scalar) + } +} + +/// Refuse to treat a weaker invariance status as shared meaning. +/// +/// # Errors +/// +/// Returns [`InvarianceError::InvarianceTooWeakForSharedMeaning`] when the +/// status is only configural. +pub fn refuse_noninvariant_as_shared_meaning( + level: InvarianceLevel, +) -> Result<(), InvarianceError> { + if level.licenses_shared_meaning() { + Ok(()) + } else { + Err(InvarianceError::InvarianceTooWeakForSharedMeaning) + } +} + +#[cfg(test)] +mod tests { + use super::InvarianceLevel; + use crate::InvarianceError; + + #[test] + fn wire_names_round_trip() { + for level in [ + InvarianceLevel::Configural, + InvarianceLevel::Metric, + InvarianceLevel::Scalar, + ] { + assert_eq!( + InvarianceLevel::from_wire_name(level.wire_name()).expect("round trip"), + level + ); + } + assert_eq!( + InvarianceLevel::from_wire_name("partial"), + Err(InvarianceError::UnknownInvarianceLevel) + ); + } +} diff --git a/crates/measurement_invariance/tests/crate_contract.rs b/crates/measurement_invariance/tests/crate_contract.rs new file mode 100644 index 000000000..65abcc7eb --- /dev/null +++ b/crates/measurement_invariance/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `measurement_invariance` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "measurement_invariance"); +} diff --git a/crates/measurement_invariance/tests/invariance_status_contract.rs b/crates/measurement_invariance/tests/invariance_status_contract.rs new file mode 100644 index 000000000..1615173e5 --- /dev/null +++ b/crates/measurement_invariance/tests/invariance_status_contract.rs @@ -0,0 +1,60 @@ +//! Shared meaning requires an explicit invariance status and computed loading RMSE. + +use measurement_invariance::{ + GroupLoading, InvarianceError, InvarianceLevel, loading_root_mean_square_error, + refuse_noninvariant_as_shared_meaning, +}; + +#[test] +fn configural_status_cannot_claim_shared_metric_meaning() { + assert_eq!( + refuse_noninvariant_as_shared_meaning(InvarianceLevel::Configural), + Err(InvarianceError::InvarianceTooWeakForSharedMeaning) + ); + assert_eq!( + refuse_noninvariant_as_shared_meaning(InvarianceLevel::Metric), + Ok(()) + ); + assert_eq!( + refuse_noninvariant_as_shared_meaning(InvarianceLevel::Scalar), + Ok(()) + ); +} + +#[test] +fn aligned_loadings_have_lower_computed_rmse_than_a_crossed_collapse() { + let truth = [ + GroupLoading::new(0, 0.80), + GroupLoading::new(1, 0.80), + GroupLoading::new(0, 0.40), + GroupLoading::new(1, 0.40), + ]; + let aligned = truth; + let crossed = [ + GroupLoading::new(0, 0.80), + GroupLoading::new(1, 0.40), + GroupLoading::new(0, 0.40), + GroupLoading::new(1, 0.80), + ]; + + let aligned_rmse = loading_root_mean_square_error(&truth, &aligned).expect("aligned"); + let crossed_rmse = loading_root_mean_square_error(&truth, &crossed).expect("crossed"); + let expected = { + let mut sum_squares = 0.0_f64; + for (truth_row, decided_row) in truth.iter().zip(aligned.iter()) { + let residual = decided_row.loading() - truth_row.loading(); + sum_squares += residual * residual; + } + (sum_squares / f64::from(u32::try_from(truth.len()).expect("len"))).sqrt() + }; + assert!((aligned_rmse - expected).abs() < f64::EPSILON); + assert!(aligned_rmse < crossed_rmse); +} + +#[test] +fn empty_or_non_finite_loadings_fail_closed() { + assert_eq!( + loading_root_mean_square_error(&[], &[]), + Err(InvarianceError::InvalidLoadingPayload) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index afada87ae..ac98e0672 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -21,7 +21,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | known-truth temporal/event simulation manifests | PRD; TRD; Test Strategy | `tepp_simulation` on protected main; recovery metrics in `validation_core` | implemented-main | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | | immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest contract on protected main; `persistence_postgres` append-only SQL insert/lookup for `reproducibility_manifest`, `corpus_split_manifest`, `model_run`, and `model_artifact` (migration `0003`); full physical ERD constraints remaining | partial | -| multilingual shared latent semantic space | PRD; ADR 0004 | future semantic/concept/topic crates | accepted-target | +| multilingual shared latent semantic space | PRD; ADR 0004 | `measurement_invariance` configural/metric/scalar status and loading RMSE on the active PR; remaining language-profile alignment remains accepted-target | active-PR | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | future `topic_measurement` | accepted-target | | global P0 topic identity with activity/dormancy/reactivation | ADR 0012 | future topic lineage/activity state | accepted-target | | no default stopword deletion / no TF-IDF-BM25 inferential weighting | ADR 0004/0012; PRD/TRD | future semantic/method-source model | accepted-target | diff --git a/docs/adr/0004-shared-multilingual-latent-space.md b/docs/adr/0004-shared-multilingual-latent-space.md index c8da25b55..383f273ad 100644 --- a/docs/adr/0004-shared-multilingual-latent-space.md +++ b/docs/adr/0004-shared-multilingual-latent-space.md @@ -1,7 +1,7 @@ # ADR 0004 — Shared multilingual latent semantic space **Decision status:** Accepted -**Implementation maturity:** accepted-target +**Implementation maturity:** active-PR — `measurement_invariance` records configural/metric/scalar status and refuses shared meaning from configural structure alone; remaining language-profile alignment remains accepted-target **Date:** 2026-08-05 **Supersedes:** None. ADR 0012 governs the complete topic-estimator/backend/global-topic contract built on this multilingual measurement decision. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1a9a7b315..506b22af9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -9,7 +9,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | | [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. | +| [0004](0004-shared-multilingual-latent-space.md) | One shared multilingual latent space with explicit invariance status | Accepted | active-PR | Invariance status gates in `measurement_invariance` on the active PR; remaining language-profile alignment remains accepted-target. ADR 0012 owns the full topic-estimator 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. | | [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. | diff --git a/docs/research/measurement-invariance-status.md b/docs/research/measurement-invariance-status.md new file mode 100644 index 000000000..2c6f0563b --- /dev/null +++ b/docs/research/measurement-invariance-status.md @@ -0,0 +1,34 @@ +# Measurement invariance status (doctoring) + +## Scope + +`measurement_invariance` records an explicit configural, metric, or scalar +status. Configural structure alone cannot license shared metric meaning. +Recovered group loadings are scored with computed RMSE against known truth. + +This slice does not fit ESEM/DSEM, claim partial invariance, or treat a +language profile as aligned from architecture alone. + +## Authority + +### Normative TEPP contract + +- `docs/adr/0004-shared-multilingual-latent-space.md` — equivalent meanings + must be aligned and tested for measurement invariance. +- `docs/adr/0005-posterior-esem-dsem.md` — longitudinal invariance is a + psychometric requirement, not an implicit property of a shared space. + +### Supporting literature + +Meredith (1993) distinguishes configural, weak/metric, and strong/scalar +invariance. Marsh et al. (2014) place those tests inside an ESEM program. +They do **not** authorize treating configural similarity as shared scores. + +Meredith, W. (1993). Measurement invariance, factor analysis and factorial +invariance. *Psychometrika, 58*(4), 525–543. +https://doi.org/10.1007/BF02294825 + +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 diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index b4b144684..918173502 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -12,7 +12,9 @@ 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. +Meredith, W. (1993). Measurement invariance, factor analysis and factorial invariance. *Psychometrika, 58*(4), 525–543. https://doi.org/10.1007/BF02294825 + +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. `measurement_invariance` records configural, metric, and scalar status and refuses shared metric meaning from configural structure alone. ## 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 e367a798f..1a16cd6ca 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 | naruon HTTP interchange | unknown-field/version/limit + naruon HTTPS interchange tests | Task 12 / PR #21; live HTTP service remaining | +| Measurement invariance status | `measurement_invariance` | active-PR | this PR | configural refusal + loading RMSE | ADR 0004/0005 | | 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 c7b1ecf58..615817d55 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -23,6 +23,7 @@ "tepp_simulation", "validation_core", "tepp_api", + "measurement_invariance", ) REQUIRED_CI_SNIPPETS: tuple[str, ...] = (