From 21b567b41784f558d1209532615cbf400a5152ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 10:24:18 +0900 Subject: [PATCH 1/3] feat(longitudinal): keep unit means out of within-unit change Separate between-unit means from occasion residuals, refuse scoring a between component as within-unit change, and recover known components with lower computed RMSE than a grand-mean pooled collapse. --- ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + Cargo.lock | 4 + Cargo.toml | 2 + README.md | 3 +- crates/longitudinal_core/Cargo.toml | 17 ++ crates/longitudinal_core/src/component.rs | 132 ++++++++++++ crates/longitudinal_core/src/decompose.rs | 195 ++++++++++++++++++ crates/longitudinal_core/src/error.rs | 60 ++++++ crates/longitudinal_core/src/level.rs | 76 +++++++ crates/longitudinal_core/src/lib.rs | 27 +++ .../longitudinal_core/tests/crate_contract.rs | 7 + .../tests/within_between_contract.rs | 89 ++++++++ docs/TRACEABILITY.md | 2 +- docs/adr/0005-posterior-esem-dsem.md | 2 +- docs/adr/README.md | 2 +- docs/research/longitudinal-within-between.md | 33 +++ docs/research/standards-and-literature.md | 4 +- docs/validation/temporal-event-foundation.md | 1 + scripts/check_workspace_contract.py | 1 + tests/quality/test_check_docstrings.py | 2 +- 21 files changed, 655 insertions(+), 6 deletions(-) create mode 100644 crates/longitudinal_core/Cargo.toml create mode 100644 crates/longitudinal_core/src/component.rs create mode 100644 crates/longitudinal_core/src/decompose.rs create mode 100644 crates/longitudinal_core/src/error.rs create mode 100644 crates/longitudinal_core/src/level.rs create mode 100644 crates/longitudinal_core/src/lib.rs create mode 100644 crates/longitudinal_core/tests/crate_contract.rs create mode 100644 crates/longitudinal_core/tests/within_between_contract.rs create mode 100644 docs/research/longitudinal-within-between.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe514db6..16e7ff440 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 | +| `longitudinal_core` | within/between decomposition; refuse between-as-within; component RMSE | 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..6adf2e6c2 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 +- `longitudinal_core` within/between decomposition: unit means stay between-unit components, occasion residuals stay within-unit change, and recovered components match known truth with lower computed RMSE than a grand-mean pooled 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..30e3680d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -687,6 +687,10 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "longitudinal_core" +version = "0.1.0" + [[package]] name = "md-5" version = "0.10.6" diff --git a/Cargo.toml b/Cargo.toml index 925659406..ebb47fad1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/longitudinal_core", ] default-members = [ "crates/evidence_core", @@ -23,6 +24,7 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/longitudinal_core", ] [workspace.package] diff --git a/README.md b/README.md index ae74015d3..455527b6a 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/longitudinal_core ``` ## Local verification diff --git a/crates/longitudinal_core/Cargo.toml b/crates/longitudinal_core/Cargo.toml new file mode 100644 index 000000000..72828a1af --- /dev/null +++ b/crates/longitudinal_core/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "longitudinal_core" +description = "Within/between decomposition gates and component 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/longitudinal_core/src/component.rs b/crates/longitudinal_core/src/component.rs new file mode 100644 index 000000000..45fc8e8e8 --- /dev/null +++ b/crates/longitudinal_core/src/component.rs @@ -0,0 +1,132 @@ +//! Known-truth RMSE for within/between components. + +use crate::{ComponentLevel, LongitudinalError}; + +/// One unit-specific within or between component. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ComponentValue { + unit_index: u32, + occasion_index: u32, + level: ComponentLevel, + value: f64, +} + +impl ComponentValue { + /// Construct a finite component record. + /// + /// Non-finite values are rejected later by + /// [`component_root_mean_square_error`]; this constructor keeps the record + /// transparent so tests can compute the same residual. + #[must_use] + pub const fn new( + unit_index: u32, + occasion_index: u32, + level: ComponentLevel, + value: f64, + ) -> Self { + Self { + unit_index, + occasion_index, + level, + value, + } + } + + /// Return the unit index. + #[must_use] + pub const fn unit_index(self) -> u32 { + self.unit_index + } + + /// Return the occasion index. + #[must_use] + pub const fn occasion_index(self) -> u32 { + self.occasion_index + } + + /// Return the component level. + #[must_use] + pub const fn level(self) -> ComponentLevel { + self.level + } + + /// Return the component value. + #[must_use] + pub const fn value(self) -> f64 { + self.value + } +} + +/// RMSE of recovered components against known-truth components. +/// +/// # Errors +/// +/// Returns [`LongitudinalError::InvalidComponentPayload`] when either slice is +/// empty, the lengths differ, a unit/occasion/level identity mismatches, or a +/// value is non-finite. +pub fn component_root_mean_square_error( + truth: &[ComponentValue], + decided: &[ComponentValue], +) -> Result { + if truth.is_empty() || truth.len() != decided.len() { + return Err(LongitudinalError::InvalidComponentPayload); + } + let mut sum_squares = 0.0_f64; + for (truth_row, decided_row) in truth.iter().zip(decided) { + if truth_row.unit_index() != decided_row.unit_index() + || truth_row.occasion_index() != decided_row.occasion_index() + || truth_row.level() != decided_row.level() + || !truth_row.value().is_finite() + || !decided_row.value().is_finite() + { + return Err(LongitudinalError::InvalidComponentPayload); + } + let residual = decided_row.value() - truth_row.value(); + sum_squares += residual * residual; + } + Ok((sum_squares / truth.len() as f64).sqrt()) +} + +#[cfg(test)] +mod tests { + use super::{ComponentValue, component_root_mean_square_error}; + use crate::{ComponentLevel, LongitudinalError}; + + #[test] + fn mismatched_identity_and_nan_fail_closed() { + let truth = [ComponentValue::new(0, 0, ComponentLevel::Between, 0.5)]; + let other_unit = [ComponentValue::new(1, 0, ComponentLevel::Between, 0.5)]; + assert_eq!( + component_root_mean_square_error(&truth, &other_unit), + Err(LongitudinalError::InvalidComponentPayload) + ); + let other_level = [ComponentValue::new(0, 0, ComponentLevel::Within, 0.5)]; + assert_eq!( + component_root_mean_square_error(&truth, &other_level), + Err(LongitudinalError::InvalidComponentPayload) + ); + let other_occasion = [ComponentValue::new(0, 1, ComponentLevel::Between, 0.5)]; + assert_eq!( + component_root_mean_square_error(&truth, &other_occasion), + Err(LongitudinalError::InvalidComponentPayload) + ); + let nan = [ComponentValue::new(0, 0, ComponentLevel::Between, f64::NAN)]; + assert_eq!( + component_root_mean_square_error(&truth, &nan), + Err(LongitudinalError::InvalidComponentPayload) + ); + let nan_truth = [ComponentValue::new(0, 0, ComponentLevel::Between, f64::NAN)]; + assert_eq!( + component_root_mean_square_error(&nan_truth, &truth), + Err(LongitudinalError::InvalidComponentPayload) + ); + assert_eq!( + component_root_mean_square_error(&truth, &[]), + Err(LongitudinalError::InvalidComponentPayload) + ); + assert_eq!( + ComponentValue::new(2, 3, ComponentLevel::Within, 0.1).occasion_index(), + 3 + ); + } +} diff --git a/crates/longitudinal_core/src/decompose.rs b/crates/longitudinal_core/src/decompose.rs new file mode 100644 index 000000000..320b2bf5d --- /dev/null +++ b/crates/longitudinal_core/src/decompose.rs @@ -0,0 +1,195 @@ +//! Unit-mean centering that separates between from within residuals. + +use crate::{ComponentLevel, ComponentValue, LongitudinalError}; + +/// One occasion score for one unit. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OccasionObservation { + unit_index: u32, + occasion_index: u32, + score: f64, +} + +impl OccasionObservation { + /// Construct an occasion score record. + #[must_use] + pub const fn new(unit_index: u32, occasion_index: u32, score: f64) -> Self { + Self { + unit_index, + occasion_index, + score, + } + } + + /// Return the unit index. + #[must_use] + pub const fn unit_index(self) -> u32 { + self.unit_index + } + + /// Return the occasion index. + #[must_use] + pub const fn occasion_index(self) -> u32 { + self.occasion_index + } + + /// Return the observed score. + #[must_use] + pub const fn score(self) -> f64 { + self.score + } +} + +/// Decompose occasion scores into unit means and within residuals. +/// +/// Each unit contributes one between component at occasion `0` and one within +/// residual per observed occasion. Units and occasions are emitted in sorted +/// order so recovery tests can pair known truth without extra matching. +/// +/// # Errors +/// +/// Returns [`LongitudinalError::InvalidObservationPayload`] when fewer than two +/// units are present, any unit has fewer than two occasions, a `(unit, +/// occasion)` pair is duplicated, or a score is non-finite. +pub fn decompose_within_between( + observations: &[OccasionObservation], +) -> Result, LongitudinalError> { + if observations.len() < 4 { + return Err(LongitudinalError::InvalidObservationPayload); + } + let mut rows: Vec = Vec::with_capacity(observations.len()); + for observation in observations { + if !observation.score().is_finite() { + return Err(LongitudinalError::InvalidObservationPayload); + } + if rows.iter().any(|seen| { + seen.unit_index() == observation.unit_index() + && seen.occasion_index() == observation.occasion_index() + }) { + return Err(LongitudinalError::InvalidObservationPayload); + } + rows.push(*observation); + } + rows.sort_by_key(|row| (row.unit_index(), row.occasion_index())); + + let mut unit_starts: Vec<(u32, usize, usize)> = Vec::new(); + let mut cursor = 0_usize; + while cursor < rows.len() { + let unit = rows[cursor].unit_index(); + let start = cursor; + cursor += 1; + while cursor < rows.len() && rows[cursor].unit_index() == unit { + cursor += 1; + } + unit_starts.push((unit, start, cursor)); + } + if unit_starts.len() < 2 { + return Err(LongitudinalError::InvalidObservationPayload); + } + + let mut components = Vec::new(); + for &(unit, start, end) in &unit_starts { + let count = end - start; + if count < 2 { + return Err(LongitudinalError::InvalidObservationPayload); + } + let mut total = 0.0_f64; + for row in &rows[start..end] { + total += row.score(); + } + let mean = total / count as f64; + if !mean.is_finite() { + return Err(LongitudinalError::InvalidObservationPayload); + } + components.push(ComponentValue::new(unit, 0, ComponentLevel::Between, mean)); + for row in &rows[start..end] { + components.push(ComponentValue::new( + unit, + row.occasion_index(), + ComponentLevel::Within, + row.score() - mean, + )); + } + } + Ok(components) +} + +#[cfg(test)] +mod tests { + use super::{OccasionObservation, decompose_within_between}; + use crate::{ComponentLevel, LongitudinalError}; + + #[test] + fn sparse_duplicate_and_nan_fail_closed() { + let one_unit = [ + OccasionObservation::new(0, 0, 1.0), + OccasionObservation::new(0, 1, 2.0), + ]; + assert_eq!( + decompose_within_between(&one_unit), + Err(LongitudinalError::InvalidObservationPayload) + ); + let one_unit_long = [ + OccasionObservation::new(0, 0, 1.0), + OccasionObservation::new(0, 1, 2.0), + OccasionObservation::new(0, 2, 3.0), + OccasionObservation::new(0, 3, 4.0), + ]; + assert_eq!( + decompose_within_between(&one_unit_long), + Err(LongitudinalError::InvalidObservationPayload) + ); + let short_unit = [ + OccasionObservation::new(0, 0, 1.0), + OccasionObservation::new(0, 1, 2.0), + OccasionObservation::new(1, 0, 3.0), + OccasionObservation::new(1, 1, 4.0), + OccasionObservation::new(2, 0, 5.0), + ]; + assert_eq!( + decompose_within_between(&short_unit), + Err(LongitudinalError::InvalidObservationPayload) + ); + let duplicate = [ + OccasionObservation::new(0, 0, 1.0), + OccasionObservation::new(0, 0, 2.0), + OccasionObservation::new(1, 0, 3.0), + OccasionObservation::new(1, 1, 4.0), + ]; + assert_eq!( + decompose_within_between(&duplicate), + Err(LongitudinalError::InvalidObservationPayload) + ); + let nan = [ + OccasionObservation::new(0, 0, f64::NAN), + OccasionObservation::new(0, 1, 2.0), + OccasionObservation::new(1, 0, 3.0), + OccasionObservation::new(1, 1, 4.0), + ]; + assert_eq!( + decompose_within_between(&nan), + Err(LongitudinalError::InvalidObservationPayload) + ); + let overflow = [ + OccasionObservation::new(0, 0, f64::MAX), + OccasionObservation::new(0, 1, f64::MAX), + OccasionObservation::new(1, 0, 0.0), + OccasionObservation::new(1, 1, 0.0), + ]; + assert_eq!( + decompose_within_between(&overflow), + Err(LongitudinalError::InvalidObservationPayload) + ); + let recovered = decompose_within_between(&[ + OccasionObservation::new(1, 1, 4.0), + OccasionObservation::new(0, 1, 2.0), + OccasionObservation::new(1, 0, 2.0), + OccasionObservation::new(0, 0, 0.0), + ]) + .expect("sorted"); + assert_eq!(recovered[0].level(), ComponentLevel::Between); + assert_eq!(recovered[0].unit_index(), 0); + assert!((recovered[0].value() - 1.0).abs() < f64::EPSILON); + assert_eq!(OccasionObservation::new(9, 8, 0.0).unit_index(), 9); + } +} diff --git a/crates/longitudinal_core/src/error.rs b/crates/longitudinal_core/src/error.rs new file mode 100644 index 000000000..eb0dba3ab --- /dev/null +++ b/crates/longitudinal_core/src/error.rs @@ -0,0 +1,60 @@ +//! Fail-closed longitudinal within/between errors. + +use std::fmt; + +/// A fail-closed longitudinal-decomposition error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum LongitudinalError { + /// A between-unit component was treated as within-unit change. + BetweenIsNotWithinChange, + /// An unknown component-level wire name was supplied. + UnknownComponentLevel, + /// Component slices were empty, length-mismatched, or non-finite. + InvalidComponentPayload, + /// Observations were empty, sparse, duplicated, or non-finite. + InvalidObservationPayload, +} + +impl fmt::Display for LongitudinalError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::BetweenIsNotWithinChange => "between component is not within-unit change", + Self::UnknownComponentLevel => "unknown component level", + Self::InvalidComponentPayload => "invalid longitudinal component payload", + Self::InvalidObservationPayload => "invalid longitudinal observation payload", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for LongitudinalError {} + +#[cfg(test)] +mod tests { + use super::LongitudinalError; + + #[test] + fn error_messages_are_stable() { + for (error, message) in [ + ( + LongitudinalError::BetweenIsNotWithinChange, + "between component is not within-unit change", + ), + ( + LongitudinalError::UnknownComponentLevel, + "unknown component level", + ), + ( + LongitudinalError::InvalidComponentPayload, + "invalid longitudinal component payload", + ), + ( + LongitudinalError::InvalidObservationPayload, + "invalid longitudinal observation payload", + ), + ] { + assert_eq!(error.to_string(), message); + } + } +} diff --git a/crates/longitudinal_core/src/level.rs b/crates/longitudinal_core/src/level.rs new file mode 100644 index 000000000..a371e28fe --- /dev/null +++ b/crates/longitudinal_core/src/level.rs @@ -0,0 +1,76 @@ +//! Explicit within/between component status. + +use crate::LongitudinalError; + +/// Established longitudinal component level. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ComponentLevel { + /// Stable between-unit component. + Between, + /// Occasion-specific within-unit residual. + Within, +} + +impl ComponentLevel { + /// Stable wire name. + #[must_use] + pub const fn wire_name(self) -> &'static str { + match self { + Self::Between => "between", + Self::Within => "within", + } + } + + /// Parse a stable wire component-level name. + /// + /// # Errors + /// + /// Returns [`LongitudinalError::UnknownComponentLevel`] for unrecognized names. + pub fn from_wire_name(name: &str) -> Result { + match name { + "between" => Ok(Self::Between), + "within" => Ok(Self::Within), + _ => Err(LongitudinalError::UnknownComponentLevel), + } + } + + /// Return whether this level is within-unit change. + #[must_use] + pub const fn is_within_change(self) -> bool { + matches!(self, Self::Within) + } +} + +/// Refuse to treat a between-unit component as within-unit change. +/// +/// # Errors +/// +/// Returns [`LongitudinalError::BetweenIsNotWithinChange`] when the component +/// is between-unit. +pub fn refuse_between_as_within_change(level: ComponentLevel) -> Result<(), LongitudinalError> { + if level.is_within_change() { + Ok(()) + } else { + Err(LongitudinalError::BetweenIsNotWithinChange) + } +} + +#[cfg(test)] +mod tests { + use super::ComponentLevel; + use crate::LongitudinalError; + + #[test] + fn wire_names_round_trip() { + for level in [ComponentLevel::Between, ComponentLevel::Within] { + assert_eq!( + ComponentLevel::from_wire_name(level.wire_name()).expect("round trip"), + level + ); + } + assert_eq!( + ComponentLevel::from_wire_name("pooled"), + Err(LongitudinalError::UnknownComponentLevel) + ); + } +} diff --git a/crates/longitudinal_core/src/lib.rs b/crates/longitudinal_core/src/lib.rs new file mode 100644 index 000000000..31996b824 --- /dev/null +++ b/crates/longitudinal_core/src/lib.rs @@ -0,0 +1,27 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Within/between decomposition for longitudinal scores. +//! +//! Stable between-unit components cannot be scored as within-unit change. +//! Recovery reports computed component RMSE against known truth (ADR 0005). + +mod component; +mod decompose; +mod error; +mod level; + +/// One unit-specific within or between component. +pub use component::ComponentValue; +/// RMSE of recovered components against known truth. +pub use component::component_root_mean_square_error; +/// One occasion score for one unit. +pub use decompose::OccasionObservation; +/// Decompose occasion scores into unit means and within residuals. +pub use decompose::decompose_within_between; +/// Fail-closed longitudinal-decomposition errors. +pub use error::LongitudinalError; +/// Established longitudinal component level. +pub use level::ComponentLevel; +/// Refuse to treat a between-unit component as within-unit change. +pub use level::refuse_between_as_within_change; diff --git a/crates/longitudinal_core/tests/crate_contract.rs b/crates/longitudinal_core/tests/crate_contract.rs new file mode 100644 index 000000000..8409af201 --- /dev/null +++ b/crates/longitudinal_core/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `longitudinal_core` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "longitudinal_core"); +} diff --git a/crates/longitudinal_core/tests/within_between_contract.rs b/crates/longitudinal_core/tests/within_between_contract.rs new file mode 100644 index 000000000..d176edf7b --- /dev/null +++ b/crates/longitudinal_core/tests/within_between_contract.rs @@ -0,0 +1,89 @@ +//! Between-unit differences cannot be scored as within-unit change. + +use longitudinal_core::{ + ComponentLevel, ComponentValue, LongitudinalError, OccasionObservation, + component_root_mean_square_error, decompose_within_between, refuse_between_as_within_change, +}; + +#[test] +fn between_component_cannot_claim_within_unit_change() { + assert_eq!( + refuse_between_as_within_change(ComponentLevel::Between), + Err(LongitudinalError::BetweenIsNotWithinChange) + ); + assert_eq!( + refuse_between_as_within_change(ComponentLevel::Within), + Ok(()) + ); +} + +#[test] +fn decomposed_components_have_lower_computed_rmse_than_a_pooled_collapse() { + let observations = [ + OccasionObservation::new(0, 0, 2.0), + OccasionObservation::new(0, 1, 2.2), + OccasionObservation::new(1, 0, 0.0), + OccasionObservation::new(1, 1, 0.4), + ]; + let recovered = decompose_within_between(&observations).expect("decompose"); + let truth = recovered.clone(); + let grand_mean = { + let mut total = 0.0_f64; + for observation in &observations { + total += observation.score(); + } + total / f64::from(u32::try_from(observations.len()).expect("len")) + }; + let collapse: Vec = recovered + .iter() + .map(|row| match row.level() { + ComponentLevel::Between => ComponentValue::new( + row.unit_index(), + row.occasion_index(), + row.level(), + grand_mean, + ), + ComponentLevel::Within => { + let score = observations + .iter() + .find(|observation| { + observation.unit_index() == row.unit_index() + && observation.occasion_index() == row.occasion_index() + }) + .expect("score") + .score(); + ComponentValue::new( + row.unit_index(), + row.occasion_index(), + row.level(), + score - grand_mean, + ) + } + }) + .collect(); + + let recovered_rmse = component_root_mean_square_error(&truth, &recovered).expect("recovered"); + let collapse_rmse = component_root_mean_square_error(&truth, &collapse).expect("collapse"); + let expected = { + let mut sum_squares = 0.0_f64; + for (truth_row, decided_row) in truth.iter().zip(recovered.iter()) { + let residual = decided_row.value() - truth_row.value(); + sum_squares += residual * residual; + } + (sum_squares / f64::from(u32::try_from(truth.len()).expect("len"))).sqrt() + }; + assert!((recovered_rmse - expected).abs() < f64::EPSILON); + assert!(recovered_rmse < collapse_rmse); +} + +#[test] +fn empty_or_non_finite_components_fail_closed() { + assert_eq!( + component_root_mean_square_error(&[], &[]), + Err(LongitudinalError::InvalidComponentPayload) + ); + assert_eq!( + decompose_within_between(&[]), + Err(LongitudinalError::InvalidObservationPayload) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index afada87ae..f5d5981af 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 | `longitudinal_core` within/between decomposition and component RMSE on the active PR; remaining ESEM/DSEM fit remains accepted-target | active-PR | | 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 09e5b0ce5..a5adb532e 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:** active-PR — `longitudinal_core` separates unit means from within residuals and refuses between-as-within change; remaining ESEM/DSEM fit remains 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 1a9a7b315..ff13a79d3 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 | active-PR | Within/between decomposition in `longitudinal_core` on the active PR; remaining ESEM/DSEM fit remains accepted-target. ADR 0012 owns the upstream topic/network contract. | | [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/longitudinal-within-between.md b/docs/research/longitudinal-within-between.md new file mode 100644 index 000000000..48c565f91 --- /dev/null +++ b/docs/research/longitudinal-within-between.md @@ -0,0 +1,33 @@ +# Longitudinal within/between decomposition (doctoring) + +## Scope + +`longitudinal_core` decomposes occasion scores into unit means (between) +and occasion residuals (within). A between-unit component cannot be scored +as within-unit change. Recovered components are scored with computed RMSE +against known truth. + +This slice does not fit DSEM, claim lagged or causal paths, or treat +irregular intervals as equally spaced. + +## Authority + +### Normative TEPP contract + +- `docs/adr/0005-posterior-esem-dsem.md` — longitudinal analysis must + separate stable between-unit components from within-unit temporal change. + +### Supporting literature + +Hamaker et al. (2015) show that a between-unit difference is not +within-unit change; pooling occasions around a grand mean confounds the +two. Asparouhov et al. (2018) place that separation inside a DSEM program. +They do **not** authorize treating a unit mean as occasion-level change. + +Hamaker, E. L., Kuiper, R. M., & Grasman, R. P. P. P. (2015). A critique of +the cross-lagged panel model. *Psychological Methods, 20*(1), 102–116. +https://doi.org/10.1037/a0038889 + +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 diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index b4b144684..4f07d5c1f 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -8,11 +8,13 @@ American Educational Research Association, American Psychological Association, & 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 +Hamaker, E. L., Kuiper, R. M., & Grasman, R. P. P. P. (2015). A critique of the cross-lagged panel model. *Psychological Methods, 20*(1), 102–116. https://doi.org/10.1037/a0038889 + 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 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. +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. `longitudinal_core` separates stable between-unit means from within-unit residuals and refuses to score a between component as within-unit change. ## 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..91147e0f9 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 | +| Longitudinal within/between | `longitudinal_core` | active-PR | this PR | between-as-within refusal + computed component RMSE | ADR 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..54661c1da 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -23,6 +23,7 @@ "tepp_simulation", "validation_core", "tepp_api", + "longitudinal_core", ) REQUIRED_CI_SNIPPETS: tuple[str, ...] = ( diff --git a/tests/quality/test_check_docstrings.py b/tests/quality/test_check_docstrings.py index 2c11f7a53..b99537c52 100644 --- a/tests/quality/test_check_docstrings.py +++ b/tests/quality/test_check_docstrings.py @@ -24,7 +24,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), 11) self.assertTrue(set(crate_roots).issubset(sources)) self.assertGreaterEqual(len(sources), len(crate_roots)) self.assertEqual(docstrings.validate_repository(REPOSITORY_ROOT), []) From b2662325441354545b34c907fc346ad06f8729d5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 04:05:20 +0900 Subject: [PATCH 2/3] test(longitudinal): cover component RMSE boundaries --- crates/longitudinal_core/src/component.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/longitudinal_core/src/component.rs b/crates/longitudinal_core/src/component.rs index 45fc8e8e8..9a7df951f 100644 --- a/crates/longitudinal_core/src/component.rs +++ b/crates/longitudinal_core/src/component.rs @@ -124,6 +124,12 @@ mod tests { component_root_mean_square_error(&truth, &[]), Err(LongitudinalError::InvalidComponentPayload) ); + let valid = [ComponentValue::new(0, 0, ComponentLevel::Between, 0.5)]; + assert_eq!(component_root_mean_square_error(&truth, &valid), Ok(0.0)); + assert_eq!( + component_root_mean_square_error(&[], &valid), + Err(LongitudinalError::InvalidComponentPayload) + ); assert_eq!( ComponentValue::new(2, 3, ComponentLevel::Within, 0.1).occasion_index(), 3 From 605daf4e57a82d50f156c2e0bcfbcb0c4707ac63 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 24 Aug 2026 16:51:11 +0900 Subject: [PATCH 3/3] docs(longitudinal): align within-between decomposition evidence --- ARCHITECTURE.md | 2 +- README.md | 10 +-- crates/longitudinal_core/src/component.rs | 65 ++++++++++++++++--- crates/longitudinal_core/src/decompose.rs | 8 +-- .../tests/within_between_contract.rs | 39 +++++++---- docs/TRACEABILITY.md | 5 +- ...nce-reproducibility-and-split-authority.md | 2 +- docs/adr/README.md | 2 +- docs/research/standards-and-literature.md | 2 +- docs/validation/temporal-event-foundation.md | 4 +- 10 files changed, 102 insertions(+), 37 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 16e7ff440..b14f6de92 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 | -| `longitudinal_core` | within/between decomposition; refuse between-as-within; component RMSE | +| `longitudinal_core` | active-PR: within/between decomposition; refuse between-as-within; component RMSE | 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/README.md b/README.md index 455527b6a..2db467d54 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,12 @@ implemented in Rust. ## Current implementation state -This branch establishes the Task 1 Rust workspace and quality-gate foundation. -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. +This branch establishes the Rust workspace, quality-gate foundation, and the +longitudinal within/between decomposition capability. The eleven bounded crates +compile independently. `longitudinal_core` exposes within/between decomposition +and component RMSE APIs; the remaining crates expose no placeholder production +APIs, and domain behavior for them begins in Task 2 with immutable evidence +identifiers and source records. ```text crates/evidence_core diff --git a/crates/longitudinal_core/src/component.rs b/crates/longitudinal_core/src/component.rs index 9a7df951f..846487af6 100644 --- a/crates/longitudinal_core/src/component.rs +++ b/crates/longitudinal_core/src/component.rs @@ -12,11 +12,10 @@ pub struct ComponentValue { } impl ComponentValue { - /// Construct a finite component record. + /// Construct a component record from its identity fields and raw value. /// - /// Non-finite values are rejected later by - /// [`component_root_mean_square_error`]; this constructor keeps the record - /// transparent so tests can compute the same residual. + /// The value is stored exactly as given, including non-finite values; + /// this constructor performs no validation. #[must_use] pub const fn new( unit_index: u32, @@ -59,11 +58,14 @@ impl ComponentValue { /// RMSE of recovered components against known-truth components. /// +/// The sum of squared residuals is accumulated with max-magnitude scaling so +/// large finite residuals cannot overflow to infinity. +/// /// # Errors /// /// Returns [`LongitudinalError::InvalidComponentPayload`] when either slice is -/// empty, the lengths differ, a unit/occasion/level identity mismatches, or a -/// value is non-finite. +/// empty, the lengths differ, a unit/occasion/level identity mismatches, a +/// value or a computed residual is non-finite. pub fn component_root_mean_square_error( truth: &[ComponentValue], decided: &[ComponentValue], @@ -71,7 +73,8 @@ pub fn component_root_mean_square_error( if truth.is_empty() || truth.len() != decided.len() { return Err(LongitudinalError::InvalidComponentPayload); } - let mut sum_squares = 0.0_f64; + let mut scale = 0.0_f64; + let mut scaled_sum_squares = 0.0_f64; for (truth_row, decided_row) in truth.iter().zip(decided) { if truth_row.unit_index() != decided_row.unit_index() || truth_row.occasion_index() != decided_row.occasion_index() @@ -82,9 +85,20 @@ pub fn component_root_mean_square_error( return Err(LongitudinalError::InvalidComponentPayload); } let residual = decided_row.value() - truth_row.value(); - sum_squares += residual * residual; + if !residual.is_finite() { + return Err(LongitudinalError::InvalidComponentPayload); + } + let magnitude = residual.abs(); + if magnitude > scale { + let ratio = scale / magnitude; + scaled_sum_squares = 1.0 + scaled_sum_squares * ratio * ratio; + scale = magnitude; + } else if scale > 0.0 { + let ratio = magnitude / scale; + scaled_sum_squares += ratio * ratio; + } } - Ok((sum_squares / truth.len() as f64).sqrt()) + Ok(scale * (scaled_sum_squares / truth.len() as f64).sqrt()) } #[cfg(test)] @@ -92,6 +106,39 @@ mod tests { use super::{ComponentValue, component_root_mean_square_error}; use crate::{ComponentLevel, LongitudinalError}; + #[test] + fn maximal_residuals_do_not_overflow() { + let truth = [ + ComponentValue::new(0, 0, ComponentLevel::Between, 0.0), + ComponentValue::new(1, 0, ComponentLevel::Between, 0.0), + ]; + let maxed = [ + ComponentValue::new(0, 0, ComponentLevel::Between, f64::MAX), + ComponentValue::new(1, 0, ComponentLevel::Between, f64::MAX), + ]; + assert_eq!( + component_root_mean_square_error(&truth, &maxed), + Ok(f64::MAX) + ); + let partial_extreme = [ + ComponentValue::new(0, 0, ComponentLevel::Between, f64::MAX), + ComponentValue::new(1, 0, ComponentLevel::Between, 0.0), + ]; + let expected = f64::MAX / f64::sqrt(2.0); + let got = component_root_mean_square_error(&truth, &partial_extreme).expect("scaled rmse"); + assert!((got - expected).abs() <= expected * 4.0 * f64::EPSILON); + } + + #[test] + fn overflowing_residual_fails_closed() { + let truth = [ComponentValue::new(0, 0, ComponentLevel::Within, -f64::MAX)]; + let decided = [ComponentValue::new(0, 0, ComponentLevel::Within, f64::MAX)]; + assert_eq!( + component_root_mean_square_error(&truth, &decided), + Err(LongitudinalError::InvalidComponentPayload) + ); + } + #[test] fn mismatched_identity_and_nan_fail_closed() { let truth = [ComponentValue::new(0, 0, ComponentLevel::Between, 0.5)]; diff --git a/crates/longitudinal_core/src/decompose.rs b/crates/longitudinal_core/src/decompose.rs index 320b2bf5d..0a24ca650 100644 --- a/crates/longitudinal_core/src/decompose.rs +++ b/crates/longitudinal_core/src/decompose.rs @@ -1,5 +1,7 @@ //! Unit-mean centering that separates between from within residuals. +use std::collections::HashSet; + use crate::{ComponentLevel, ComponentValue, LongitudinalError}; /// One occasion score for one unit. @@ -58,14 +60,12 @@ pub fn decompose_within_between( return Err(LongitudinalError::InvalidObservationPayload); } let mut rows: Vec = Vec::with_capacity(observations.len()); + let mut seen_pairs: HashSet<(u32, u32)> = HashSet::with_capacity(observations.len()); for observation in observations { if !observation.score().is_finite() { return Err(LongitudinalError::InvalidObservationPayload); } - if rows.iter().any(|seen| { - seen.unit_index() == observation.unit_index() - && seen.occasion_index() == observation.occasion_index() - }) { + if !seen_pairs.insert((observation.unit_index(), observation.occasion_index())) { return Err(LongitudinalError::InvalidObservationPayload); } rows.push(*observation); diff --git a/crates/longitudinal_core/tests/within_between_contract.rs b/crates/longitudinal_core/tests/within_between_contract.rs index d176edf7b..8e8d54036 100644 --- a/crates/longitudinal_core/tests/within_between_contract.rs +++ b/crates/longitudinal_core/tests/within_between_contract.rs @@ -18,7 +18,7 @@ fn between_component_cannot_claim_within_unit_change() { } #[test] -fn decomposed_components_have_lower_computed_rmse_than_a_pooled_collapse() { +fn decomposed_components_recover_known_truth_below_pooled_collapse() { let observations = [ OccasionObservation::new(0, 0, 2.0), OccasionObservation::new(0, 1, 2.2), @@ -26,7 +26,27 @@ fn decomposed_components_have_lower_computed_rmse_than_a_pooled_collapse() { OccasionObservation::new(1, 1, 0.4), ]; let recovered = decompose_within_between(&observations).expect("decompose"); - let truth = recovered.clone(); + + // Independent known truth: unit 0 has mean 2.1 with within deviations + // -0.1/+0.1; unit 1 has mean 0.2 with within deviations -0.2/+0.2. The + // truth vector is written from the statistical model, not from the + // decomposition output. + let truth = vec![ + ComponentValue::new(0, 0, ComponentLevel::Between, 2.1), + ComponentValue::new(0, 0, ComponentLevel::Within, -0.1), + ComponentValue::new(0, 1, ComponentLevel::Within, 0.1), + ComponentValue::new(1, 0, ComponentLevel::Between, 0.2), + ComponentValue::new(1, 0, ComponentLevel::Within, -0.2), + ComponentValue::new(1, 1, ComponentLevel::Within, 0.2), + ]; + assert_eq!(recovered.len(), truth.len()); + for (truth_row, recovered_row) in truth.iter().zip(recovered.iter()) { + assert_eq!(truth_row.unit_index(), recovered_row.unit_index()); + assert_eq!(truth_row.occasion_index(), recovered_row.occasion_index()); + assert_eq!(truth_row.level(), recovered_row.level()); + assert!((truth_row.value() - recovered_row.value()).abs() < 1e-12); + } + let grand_mean = { let mut total = 0.0_f64; for observation in &observations { @@ -34,7 +54,7 @@ fn decomposed_components_have_lower_computed_rmse_than_a_pooled_collapse() { } total / f64::from(u32::try_from(observations.len()).expect("len")) }; - let collapse: Vec = recovered + let collapse: Vec = truth .iter() .map(|row| match row.level() { ComponentLevel::Between => ComponentValue::new( @@ -64,15 +84,10 @@ fn decomposed_components_have_lower_computed_rmse_than_a_pooled_collapse() { let recovered_rmse = component_root_mean_square_error(&truth, &recovered).expect("recovered"); let collapse_rmse = component_root_mean_square_error(&truth, &collapse).expect("collapse"); - let expected = { - let mut sum_squares = 0.0_f64; - for (truth_row, decided_row) in truth.iter().zip(recovered.iter()) { - let residual = decided_row.value() - truth_row.value(); - sum_squares += residual * residual; - } - (sum_squares / f64::from(u32::try_from(truth.len()).expect("len"))).sqrt() - }; - assert!((recovered_rmse - expected).abs() < f64::EPSILON); + assert!(recovered_rmse < 1e-12); + // Every collapsed component misses its known-truth value by exactly 0.95, + // so the pooled collapse RMSE is 0.95. + assert!((collapse_rmse - 0.95).abs() < 1e-12); assert!(recovered_rmse < collapse_rmse); } diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index b85d07065..d0c1f9fe6 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -17,7 +17,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network on protected main; multilevel estimators remaining | partial | | leakage-safe availability/cutoff snapshots | PRD; ADR 0002/0013 | `corpus_split` on protected main | implemented-main | | recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | -| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), backup/restore integrity revalidation (active PR); remaining physical ERD constraints | partial | +| PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), and backup/restore integrity revalidation (#44 implemented-main); remaining physical ERD constraints | partial | | 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 | @@ -28,7 +28,8 @@ 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 | `longitudinal_core` within/between decomposition and component RMSE on the active PR; remaining ESEM/DSEM fit remains accepted-target | active-PR | +| longitudinal within/between decomposition | ADR 0005 | `longitudinal_core` decomposition, component RMSE, and known-truth recovery on the active PR | active-PR | +| posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | future `psychometric_core`; invariance and ESEM/DSEM fit remain accepted-target | accepted-target | | CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | future `compute_backend` | accepted-target | | TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | future `event_intelligence` | accepted-target | | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | `tepp_api` router plus future `interpretation_gateway` | partial | diff --git a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md index 685e0fdc7..a45f95ccc 100644 --- a/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md +++ b/docs/adr/0013-bitemporal-persistence-reproducibility-and-split-authority.md @@ -1,7 +1,7 @@ # ADR 0013 — Bitemporal persistence, reproducibility manifests, and split authority **Decision status:** Accepted -**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, and concurrent document-write stress implemented-main; backup/restore integrity revalidation on the active PR +**Implementation maturity:** partial — migration contracts, cutoff eligibility, in-memory bitemporal adapters, live SQL session/migration port, document SQL contracts, `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` open/execute driver, exact-head live PostgreSQL CI, tenant RLS (`tepp_app_runtime` + session GUC), append-only reproducibility-manifest SQL insert/lookup, model-run / model-artifact / corpus-split-manifest chain (migration `0003`), append-only immutability triggers (migration `0004`), temporal interval ordering CHECK constraints (migration `0005`), typed membership-assignment storage (migration `0006`), event-relation/mention/instance SQL, source-artifact SQL, audit-event action-code validation, concurrent document-write stress, and backup/restore integrity revalidation implemented-main; remaining physical ERD and DR-runbook depth accepted-target **Date:** 2026-08-12 **Supersedes:** None; complements ADR 0002 (temporal semantics), ADR 0008 (evidence identity), and ADR 0011 (service ownership). diff --git a/docs/adr/README.md b/docs/adr/README.md index fb761a448..b9908d77e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,7 +18,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0010](0010-adaptive-llm-orchestration.md) | Adaptive LLM orchestration and test-time compute | Accepted | partial | `tepp_api` router/ablation/orchestrator binding on the active PR; live NIM execution and production ablation evidence remain accepted-target. | | [0011](0011-standalone-modular-msa-boundary.md) | Standalone operation and modular CWL MSA boundary | Accepted | partial | Owns cross-service persistence/credential/API authority; no direct cross-service application-table coupling. | | [0012](0012-temporal-relational-shared-latent-topic-measurement.md) | Temporal Relational Shared-Latent Topic Measurement (TRSL-TM) | Accepted | accepted-target | Owns topic backend compatibility, global topic identity, method effects, K/model-selection prerequisites, and compositional topic coordinates. | -| [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility manifests, and relation-aware split authority | Accepted | partial | Owns PostgreSQL adapter semantics, immutable run/split manifests, leakage-safe partitions, and recovery identity; optional `live-sqlx` `PgPool`, live PG CI, tenant RLS, and `0006` membership implemented-main; `0007` retention/deletion/legal-hold on the active PR; remaining physical ERD/backup accepted-target. | +| [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility manifests, and relation-aware split authority | Accepted | partial | Owns PostgreSQL adapter semantics, immutable run/split manifests, leakage-safe partitions, and recovery identity; optional `live-sqlx` `PgPool`, live PG CI, tenant RLS, `0006` membership, `0007` retention/deletion/legal-hold, and backup/restore integrity revalidation implemented-main; remaining physical ERD/DR-runbook depth accepted-target. | | [0014](0014-scientific-claim-promotion-and-release-evidence.md) | Scientific claim promotion and release evidence authority | Accepted | partial | Separates design, implementation, scientific/product claim, and release authority; repository SBOM/provenance generator implemented, full release bundle remaining. | | [0015](0015-autonomous-development-review-and-merge-authority.md) | Autonomous development, review, and merge authority separation | Accepted | active-PR | Separates model proposal, deterministic verification, publication, independent review, and merge/release authority. | | [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | accepted-target | Separates observed evidence, detection/tracking, prediction/schema inference, temporal consistency, and promoted transition authority. | diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index 32d183d34..ff4cbccf7 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -14,7 +14,7 @@ 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. `longitudinal_core` separates stable between-unit means from within-unit residuals and refuses to score a between component as within-unit change. +TEPP applies these sources to construct definition, score interpretation, reliability, validity evidence, uncertainty, consequences, longitudinal invariance, ESEM cross-loadings, and DSEM (American Educational Research Association, American Psychological Association, & National Council on Measurement in Education, 2014; Asparouhov & Muthén, 2009; Asparouhov et al., 2018; Hamaker et al., 2015; Marsh et al., 2014). Topic outputs are treated as fallible indicators or components only after their construct role is evaluated. `longitudinal_core` separates stable between-unit means from within-unit residuals and refuses to score a between component as within-unit change. ## 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 c59008ddf..30bc80a6a 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -18,12 +18,12 @@ This report tracks exact-head scientific and engineering evidence required befor | Event mention/instance | `event_core` | partial | — | unit + fail-closed promotion | Task 5 / PR #13 | | Multiple membership | `membership_core` | partial | — | unit + ESS weights | Task 7 / PR #12 + #25 | | Forward transition DAG | `relation_graph` | implemented-main | — | unit + cycle rejection | Task 6 / PR #14 | -| Bitemporal persistence + live SQL port | `persistence_postgres` | partial | backup/restore integrity | migration contracts + recording transport + optional PgPool + live CI + tenant RLS + `0005`/`0006` + event relation/mention/instance + source-artifact + audit-event + concurrent-write (#37–#43 implemented-main) + restore integrity probes (active PR) | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#43 + restore integrity | +| Bitemporal persistence + live SQL port | `persistence_postgres` | partial | backup/restore integrity | migration contracts + recording transport + optional PgPool + live CI + tenant RLS + `0005`/`0006` + event relation/mention/instance + source-artifact + audit-event + concurrent-write (#37–#43 implemented-main) + restore integrity probes (#44 implemented-main) | Task 8 / PR #16 + #23 + #26 + #27 + #29 + #30–#44 + restore integrity | | Leakage-safe splits | `corpus_split` | implemented-main | — | cutoff + co-partition tests | Task 9 / PR #17 | | 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 | -| Longitudinal within/between | `longitudinal_core` | active-PR | this PR | between-as-within refusal + computed component RMSE | ADR 0005 | +| Longitudinal within/between | `longitudinal_core` | active-PR | this PR | known-truth component recovery, computed component RMSE, grand-mean pooling baseline comparison, and between-as-within refusal | ADR 0005 | | Purpose-bound provider payloads | `tepp_api` | implemented-main | provider-payload minimization | expired/not-yet-valid/inverted/cross-tenant/impossible-calendar grant, mapping refusal, audited elevated re-id replay | ADR 0009; `docs/research/provider-payload-minimization.md` | | Adaptive orchestration router | `tepp_api` | accepted-target | active PR | mode selection, document-control denial, ablation, credential-free bind | ADR 0010; `docs/research/adaptive-orchestration-router.md` | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining |