From 035bfb087d47543fd7dd87cfdbc4edd778f4a6aa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 14:06:23 +0000 Subject: [PATCH] feat(analysis): bind irregular event-time log-rate to an analysis-run profile GAP-006 remaining operator-visible slice: cutoff-safe irregular_event_time_v1 maps discrete lags through a and refuses pooled unequal-interval coefficients. Not DSEM, not a Driver p.16 std-family restore, and not implemented-main. --- CHANGELOG.md | 6 + Cargo.lock | 1 + crates/analysis_engine/Cargo.toml | 1 + .../src/irregular_event_time_artifact.rs | 439 ++++++++++++++++++ crates/analysis_engine/src/lib.rs | 36 +- ...irregular_event_time_execution_contract.rs | 219 +++++++++ docs/TRACEABILITY.md | 1 + .../0040-irregular-event-time-analysis-run.md | 87 ++++ docs/adr/README.md | 1 + .../irregular-event-time-analysis-run.md | 15 + 10 files changed, 804 insertions(+), 2 deletions(-) create mode 100644 crates/analysis_engine/src/irregular_event_time_artifact.rs create mode 100644 crates/analysis_engine/tests/irregular_event_time_execution_contract.rs create mode 100644 docs/adr/0040-irregular-event-time-analysis-run.md create mode 100644 docs/doctoring/irregular-event-time-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..0ae8d2f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to TEPP are documented here. The format follows Keep a Changelog and semantic versioning once the first implementation release is cut. +## [Unreleased] + +### Added + +- **Irregular event-time analysis-run profile**: cutoff-safe `irregular_event_time_v1` binds `recover_event_series_mean_log_rate`, maps pooled discrete lags across unequal intervals, and maps a reference-interval lag through `a` (`analysis_engine`). Not DSEM and not implemented-main. + ## [0.2.0] - 2026-08-25 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 454a7d612..28a0f0cb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,7 @@ dependencies = [ "corpus_split", "event_core", "membership_core", + "psychometric_core", "relation_graph", "serde", "serde_json", diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 7322212b2..2ada0c3be 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -15,6 +15,7 @@ publish = false [dependencies] event_core = { path = "../event_core", version = "0.2.0" } +psychometric_core = { path = "../psychometric_core", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/analysis_engine/src/irregular_event_time_artifact.rs b/crates/analysis_engine/src/irregular_event_time_artifact.rs new file mode 100644 index 000000000..d80d827b2 --- /dev/null +++ b/crates/analysis_engine/src/irregular_event_time_artifact.rs @@ -0,0 +1,439 @@ +//! Digest-bound irregular event-time log-rate as an analysis-run profile. + +use psychometric_core::{ + CausalHeuristic, EventOccasion, LagClock, PsychometricError, claim_causal_effect, + map_discrete_lag_across_event_intervals, recover_discrete_lag_one, + recover_event_series_mean_log_rate, refuse_pooled_discrete_lag_across_unequal_intervals, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, format_digest, require_receipt_identity, + valid_identifier, +}; + +/// Versioned schema for a completed irregular event-time artifact. +pub const IRREGULAR_EVENT_TIME_ARTIFACT_SCHEMA_VERSION: &str = "tepp.irregular_event_time.v1"; +/// Model contract required by the irregular event-time execution path. +pub const IRREGULAR_EVENT_TIME_MODEL_CONTRACT_VERSION: &str = "irregular_event_time_v1"; +/// Analysis-run output profile required for an irregular event-time artifact. +pub const IRREGULAR_EVENT_TIME_OUTPUT_PROFILE: &str = "irregular_event_time_v1"; +/// Maximum canonical artifact JSON size. +pub const IRREGULAR_EVENT_TIME_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const IRREGULAR_EVENT_TIME_INFERENCE_STATUS: &str = "composed_interval_mapped_lags_not_dsem"; + +/// One already-mapped event-time score offered to a cutoff-safe run. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct IrregularEventScore { + event_time: f64, + score: f64, + available_time: AvailableTime, +} + +impl IrregularEventScore { + /// Bind one event-time score to an availability clock. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the event time or + /// score is non-finite. + pub fn new( + event_time: f64, + score: f64, + available_time: AvailableTime, + ) -> Result { + if !event_time.is_finite() || !score.is_finite() { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + event_time, + score, + available_time, + }) + } + + /// Return the event / valid time of the observation. + #[must_use] + pub const fn event_time(self) -> f64 { + self.event_time + } + + /// Return the already-mapped score. + #[must_use] + pub const fn score(self) -> f64 { + self.score + } + + /// Return the availability clock used for cutoff eligibility. + #[must_use] + pub const fn available_time(self) -> AvailableTime { + self.available_time + } +} + +/// Completed, bounded irregular event-time composition for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IrregularEventTimeArtifact { + /// Exact versioned schema identity. + pub schema_version: String, + /// Opaque accepted-run identity. + pub run_id: String, + /// Immutable source snapshot identity. + pub snapshot_id: String, + /// Historical evidence cutoff used by the composition. + pub knowledge_cutoff: String, + /// Eligible occasions after cutoff. + pub occasion_count: u64, + /// Consecutive event intervals among eligible occasions. + pub interval_count: u64, + /// Occasions excluded because availability was after the cutoff. + pub excluded_after_cutoff_count: u64, + /// Mean local log-rate `a` across eligible irregular intervals. + pub mean_log_rate: f64, + /// Discrete lag mapped onto the requested reference interval through `a`. + pub mapped_reference_lag: f64, + /// Positive reference event interval used for the mapped lag. + pub reference_delta: f64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl IrregularEventTimeArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidIrregularEventTimeArtifact`] when + /// the schema, identifiers, counts, rates, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > IRREGULAR_EVENT_TIME_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidIrregularEventTimeArtifact)?; + artifact.validate()?; + Ok(artifact) + } + + /// Serialize canonical validated artifact JSON. + /// + /// # Errors + /// + /// Returns a typed validation, serialization, or size failure. + pub fn to_json(&self) -> Result { + self.validate()?; + let payload = + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; + if payload.len() > IRREGULAR_EVENT_TIME_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + Ok(payload) + } + + /// Return the lowercase SHA-256 digest of canonical artifact JSON. + /// + /// # Errors + /// + /// Returns a typed validation or serialization failure. + pub fn sha256(&self) -> Result { + self.to_json() + .map(|json| format_digest(Sha256::digest(json.into_bytes()))) + } + + fn validate(&self) -> Result<(), AnalysisEngineError> { + if self.schema_version != IRREGULAR_EVENT_TIME_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.occasion_count < 2 + || self.interval_count == 0 + || self.interval_count != self.occasion_count.saturating_sub(1) + || !self.mean_log_rate.is_finite() + || !self.mapped_reference_lag.is_finite() + || self.mapped_reference_lag <= 0.0 + || !self.reference_delta.is_finite() + || self.reference_delta <= 0.0 + || self.inference_status != IRREGULAR_EVENT_TIME_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidIrregularEventTimeArtifact); + } + Ok(()) + } +} + +/// One completed irregular event-time artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct IrregularEventTimeExecution { + /// Digest-bound completed composition artifact. + pub artifact: IrregularEventTimeArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +struct EligibleOccasions { + occasions: Vec, + excluded_after_cutoff_count: u64, +} + +fn admit_scores_at_cutoff( + scores: &[IrregularEventScore], + knowledge_cutoff: KnowledgeCutoff, +) -> Result { + if scores.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + let mut eligible = Vec::new(); + let mut excluded_after_cutoff_count = 0_u64; + for score in scores { + if score.available_time.instant() <= knowledge_cutoff.instant() { + eligible.push(EventOccasion { + event_time: score.event_time, + score: score.score, + }); + } else { + excluded_after_cutoff_count += 1; + } + } + if eligible.len() < 2 { + return Err(AnalysisEngineError::Psychometric( + PsychometricError::InvalidNumericInput, + )); + } + Ok(EligibleOccasions { + occasions: eligible, + excluded_after_cutoff_count, + }) +} + +fn first_source_lag(occasions: &[EventOccasion]) -> Result<(f64, f64), AnalysisEngineError> { + let mut ordered = occasions.to_vec(); + ordered.sort_by(|left, right| { + left.event_time + .partial_cmp(&right.event_time) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let earlier = ordered[0]; + let later = ordered[1]; + let source_delta = later.event_time - earlier.event_time; + let discrete_lag = recover_discrete_lag_one(earlier.score, later.score)?; + Ok((discrete_lag, source_delta)) +} + +/// Execute cutoff-safe irregular event-time composition as one analysis-run profile. +/// +/// The caller supplies already-mapped event-time scores. This executor maps +/// discrete lags through the local log-rate `a` and refuses pooled discrete +/// coefficients across unequal intervals. It is not DSEM. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, psychometric +/// recovery failure, or invalid artifact error. +#[allow( + clippy::too_many_arguments, + reason = "audited cutoff, clock, pooling, and reference-interval gates" +)] +pub fn execute_irregular_event_time_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + scores: &[IrregularEventScore], + lag_clock: LagClock, + pool_unequal_intervals: bool, + reference_delta: f64, + completed_at: impl Into, +) -> Result { + request.to_json()?; + accepted.to_json()?; + require_receipt_identity(request, accepted)?; + if request.snapshot_id != snapshot_id { + return Err(AnalysisEngineError::SnapshotMismatch); + } + if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + || request.model_contract_version != IRREGULAR_EVENT_TIME_MODEL_CONTRACT_VERSION + || request.output_profile != IRREGULAR_EVENT_TIME_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + if !reference_delta.is_finite() || reference_delta <= 0.0 { + return Err(AnalysisEngineError::InvalidEvidence); + } + if pool_unequal_intervals { + refuse_pooled_discrete_lag_across_unequal_intervals(1.0, 2.0)?; + } + + let eligible = admit_scores_at_cutoff(scores, knowledge_cutoff)?; + let mean_log_rate = recover_event_series_mean_log_rate(&eligible.occasions, lag_clock)?; + let (source_lag, source_delta) = first_source_lag(&eligible.occasions)?; + let mapped_reference_lag = map_discrete_lag_across_event_intervals( + source_lag, + source_delta, + reference_delta, + lag_clock, + )?; + let _ = claim_causal_effect(CausalHeuristic::TemporalPrecedence); + + let occasion_count = u64::try_from(eligible.occasions.len()) + .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let interval_count = occasion_count + .checked_sub(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + let artifact = IrregularEventTimeArtifact { + schema_version: IRREGULAR_EVENT_TIME_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + occasion_count, + interval_count, + excluded_after_cutoff_count: eligible.excluded_after_cutoff_count, + mean_log_rate, + mapped_reference_lag, + reference_delta, + inference_status: IRREGULAR_EVENT_TIME_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "irregular_event_time", + occasion_count, + 2, + IRREGULAR_EVENT_TIME_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("irregular_event_time_artifact_{}", &digest[..16]), + digest, + IRREGULAR_EVENT_TIME_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(IrregularEventTimeExecution { + artifact, + terminal_result, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + IRREGULAR_EVENT_TIME_ARTIFACT_BYTE_LIMIT, IRREGULAR_EVENT_TIME_ARTIFACT_SCHEMA_VERSION, + IRREGULAR_EVENT_TIME_INFERENCE_STATUS, IrregularEventTimeArtifact, + }; + use crate::AnalysisEngineError; + + fn artifact() -> IrregularEventTimeArtifact { + IrregularEventTimeArtifact { + schema_version: IRREGULAR_EVENT_TIME_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + occasion_count: 3, + interval_count: 2, + excluded_after_cutoff_count: 0, + mean_log_rate: -std::f64::consts::LN_2, + mapped_reference_lag: 0.25, + reference_delta: 2.0, + inference_status: IRREGULAR_EVENT_TIME_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &IrregularEventTimeArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidIrregularEventTimeArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + IrregularEventTimeArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + IrregularEventTimeArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidIrregularEventTimeArtifact) + ); + assert_eq!( + IrregularEventTimeArtifact::from_json( + &"x".repeat(IRREGULAR_EVENT_TIME_ARTIFACT_BYTE_LIMIT + 1) + ), + Err(AnalysisEngineError::LimitExceeded) + ); + } + + #[test] + fn artifact_metadata_tampering_fails_closed() { + let artifact = artifact(); + let invalid_artifacts = [ + { + let mut value = artifact.clone(); + value.schema_version.clear(); + value + }, + { + let mut value = artifact.clone(); + value.run_id.clear(); + value + }, + { + let mut value = artifact.clone(); + value.snapshot_id.clear(); + value + }, + { + let mut value = artifact.clone(); + value.knowledge_cutoff = "invalid".into(); + value + }, + { + let mut value = artifact.clone(); + value.occasion_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.interval_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.interval_count = 4; + value + }, + { + let mut value = artifact.clone(); + value.mean_log_rate = f64::NAN; + value + }, + { + let mut value = artifact.clone(); + value.mapped_reference_lag = 0.0; + value + }, + { + let mut value = artifact.clone(); + value.reference_delta = 0.0; + value + }, + { + let mut value = artifact.clone(); + value.inference_status.clear(); + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } +} diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..da6d24f91 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -8,13 +8,16 @@ //! through [`tepp_api`]. It deliberately does not claim latent-variable or topic //! estimation authority; those estimators remain separate scientific crates. //! estimation authority; it invokes estimators through their scientific crate -//! contracts and preserves their artifact meaning. +//! contracts and preserves their artifact meaning. Irregular event-time +//! composition is invoked through [`psychometric_core`] and is not DSEM. mod case_deletion_refit; +mod irregular_event_time_artifact; mod lineage_criterion; mod topic_context_posterior; mod topic_lineage_artifact; +use psychometric_core::PsychometricError; use serde::Serialize; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -41,6 +44,13 @@ pub use case_deletion_refit::ExhaustiveCaseDeletionError; pub use case_deletion_refit::ExhaustiveCaseDeletionFits; /// Fit the full corpus and every actual one-document deletion. pub use case_deletion_refit::fit_exhaustive_case_deletion; +/// Irregular event-time composition artifact and execution contracts. +pub use irregular_event_time_artifact::{ + IRREGULAR_EVENT_TIME_ARTIFACT_BYTE_LIMIT, IRREGULAR_EVENT_TIME_ARTIFACT_SCHEMA_VERSION, + IRREGULAR_EVENT_TIME_MODEL_CONTRACT_VERSION, IRREGULAR_EVENT_TIME_OUTPUT_PROFILE, + IrregularEventScore, IrregularEventTimeArtifact, IrregularEventTimeExecution, + execute_irregular_event_time_run, +}; /// Rust-owned independent TDT link-criterion posterior fitting contracts. pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, @@ -248,6 +258,10 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// A psychometric recovery rejected the offered coordinates. + Psychometric(PsychometricError), + /// An irregular event-time artifact violated its bounded schema or counts. + InvalidIrregularEventTimeArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +276,8 @@ impl fmt::Display for AnalysisEngineError { Self::LimitExceeded => "analysis corpus exceeded its execution bound", Self::TopicMeasurement(error) => return error.fmt(formatter), Self::InvalidTopicLineageArtifact => "invalid topic lineage artifact", + Self::Psychometric(error) => return error.fmt(formatter), + Self::InvalidIrregularEventTimeArtifact => "invalid irregular event-time artifact", }; formatter.write_str(message) } @@ -281,6 +297,12 @@ impl From for AnalysisEngineError { } } +impl From for AnalysisEngineError { + fn from(error: PsychometricError) -> Self { + Self::Psychometric(error) + } +} + /// Execute the cutoff-safe temporal evidence readiness analysis. /// /// Evidence whose `available_time` is later than the request cutoff is excluded @@ -413,7 +435,8 @@ mod tests { use super::{ ANALYSIS_ARTIFACT_SCHEMA_VERSION, ANALYSIS_STATISTIC_COUNT, AnalysisCorpus, AnalysisEngineError, AnalysisEvidenceUnit, MAX_ANALYSIS_IDENTIFIER_BYTES, - MAX_EVIDENCE_UNITS, TopicMeasurementError, add_membership_count, execute_analysis_run, + MAX_EVIDENCE_UNITS, PsychometricError, TopicMeasurementError, add_membership_count, + execute_analysis_run, }; use temporal_core::{AvailableTime, EventTime}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; @@ -681,6 +704,10 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidIrregularEventTimeArtifact, + "invalid irregular event-time artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); @@ -689,6 +716,11 @@ mod tests { assert_eq!(converted.to_string(), "invalid API wire payload"); let from_topic: AnalysisEngineError = TopicMeasurementError::DidNotConverge.into(); assert_eq!(from_topic.to_string(), "topic estimator did not converge"); + let from_psychometric: AnalysisEngineError = PsychometricError::EventTimeRequired.into(); + assert_eq!( + from_psychometric.to_string(), + "discrete lag and local log-rate require event time, not another clock" + ); assert_eq!( add_membership_count(u64::MAX, 1), Err(AnalysisEngineError::ArithmeticOverflow) diff --git a/crates/analysis_engine/tests/irregular_event_time_execution_contract.rs b/crates/analysis_engine/tests/irregular_event_time_execution_contract.rs new file mode 100644 index 000000000..e1e013fa1 --- /dev/null +++ b/crates/analysis_engine/tests/irregular_event_time_execution_contract.rs @@ -0,0 +1,219 @@ +//! End-to-end contract for cutoff-safe irregular event-time composition. + +use analysis_engine::{ + AnalysisEngineError, IRREGULAR_EVENT_TIME_ARTIFACT_SCHEMA_VERSION, + IRREGULAR_EVENT_TIME_MODEL_CONTRACT_VERSION, IRREGULAR_EVENT_TIME_OUTPUT_PROFILE, + IrregularEventScore, MAX_EVIDENCE_UNITS, execute_irregular_event_time_run, +}; +use psychometric_core::{LagClock, PsychometricError}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn noiseless_scores() -> Vec { + vec![ + IrregularEventScore::new(0.0, 1.0, available("2026-07-01T00:00:00Z")).expect("t0"), + IrregularEventScore::new(1.0, 0.5, available("2026-07-01T00:00:00Z")).expect("t1"), + IrregularEventScore::new(2.0, 0.25, available("2026-07-01T00:00:00Z")).expect("t2"), + ] +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "irregular-event-time-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-irregular-event-time".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: IRREGULAR_EVENT_TIME_MODEL_CONTRACT_VERSION.into(), + output_profile: IRREGULAR_EVENT_TIME_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-irregular-event-time", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn execute( + request: &AnalysisRunRequest, + scores: &[IrregularEventScore], + clock: LagClock, + pool: bool, + reference_delta: f64, +) -> Result { + execute_irregular_event_time_run( + request, + &accepted(request), + "snapshot-irregular-event-time", + cutoff(), + scores, + clock, + pool, + reference_delta, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn noiseless_irregular_series_maps_through_local_log_rate() { + let request = request(); + let scores = noiseless_scores(); + let execution = execute(&request, &scores, LagClock::EventTime, false, 2.0).expect("execution"); + + assert_eq!( + execution.artifact.schema_version, + IRREGULAR_EVENT_TIME_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.occasion_count, 3); + assert_eq!(execution.artifact.interval_count, 2); + assert_eq!(execution.artifact.excluded_after_cutoff_count, 0); + assert!((execution.artifact.mean_log_rate - 0.5_f64.ln()).abs() < 1e-12); + assert!((execution.artifact.mapped_reference_lag - 0.25).abs() < 1e-12); + assert!((execution.artifact.reference_delta - 2.0).abs() < f64::EPSILON); + assert_eq!( + execution.artifact.inference_status, + "composed_interval_mapped_lags_not_dsem" + ); + assert_eq!( + execution.terminal_result.run_state, + AnalysisRunTerminalState::Succeeded + ); + assert_eq!( + execution.terminal_result.result_sha256.as_deref(), + Some(execution.artifact.sha256().expect("digest").as_str()) + ); + assert_eq!( + execution.terminal_result.result_schema_version.as_deref(), + Some(IRREGULAR_EVENT_TIME_ARTIFACT_SCHEMA_VERSION) + ); + assert!((scores[0].event_time() - 0.0).abs() < f64::EPSILON); + assert!((scores[0].score() - 1.0).abs() < f64::EPSILON); + assert_eq!( + scores[0].available_time(), + available("2026-07-01T00:00:00Z") + ); +} + +#[test] +fn execution_excludes_occasions_unavailable_at_the_request_cutoff() { + let request = request(); + let mut scores = noiseless_scores(); + scores.push( + IrregularEventScore::new(3.0, 0.125, available("2026-08-15T00:00:00Z")).expect("late"), + ); + let execution = execute(&request, &scores, LagClock::EventTime, false, 2.0).expect("execution"); + assert_eq!(execution.artifact.occasion_count, 3); + assert_eq!(execution.artifact.interval_count, 2); + assert_eq!(execution.artifact.excluded_after_cutoff_count, 1); + assert!((execution.artifact.mean_log_rate - 0.5_f64.ln()).abs() < 1e-12); +} + +#[test] +fn execution_refuses_pooled_lags_across_unequal_intervals() { + let request = request(); + let scores = noiseless_scores(); + assert_eq!( + execute(&request, &scores, LagClock::EventTime, true, 2.0), + Err(AnalysisEngineError::Psychometric( + PsychometricError::UnequalIntervalPoolingForbidden + )) + ); +} + +#[test] +fn execution_refuses_non_event_clocks() { + let request = request(); + let scores = noiseless_scores(); + assert_eq!( + execute(&request, &scores, LagClock::SystemTime, false, 2.0), + Err(AnalysisEngineError::Psychometric( + PsychometricError::EventTimeRequired + )) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_cutoff_and_reference_mismatch() { + let request = request(); + let scores = noiseless_scores(); + assert_eq!( + execute_irregular_event_time_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + &scores, + LagClock::EventTime, + false, + 2.0, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); + for invalid_request in [ + { + let mut value = request.clone(); + value.knowledge_cutoff = "2026-08-02T00:00:00Z".into(); + value + }, + { + let mut value = request.clone(); + value.model_contract_version = "other-model".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "other-profile".into(); + value + }, + ] { + assert_eq!( + execute(&invalid_request, &scores, LagClock::EventTime, false, 2.0,), + Err(AnalysisEngineError::InvalidEvidence) + ); + } + assert_eq!( + execute(&request, &scores, LagClock::EventTime, false, 0.0), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_non_finite_scores_empty_eligibility_and_oversize() { + assert_eq!( + IrregularEventScore::new(f64::NAN, 1.0, available("2026-07-01T00:00:00Z")), + Err(AnalysisEngineError::InvalidEvidence) + ); + let request = request(); + let late_only = vec![ + IrregularEventScore::new(0.0, 1.0, available("2026-08-15T00:00:00Z")).expect("late-a"), + IrregularEventScore::new(1.0, 0.5, available("2026-08-15T00:00:00Z")).expect("late-b"), + ]; + assert_eq!( + execute(&request, &late_only, LagClock::EventTime, false, 2.0), + Err(AnalysisEngineError::Psychometric( + PsychometricError::InvalidNumericInput + )) + ); + let oversized = vec![ + IrregularEventScore::new(0.0, 1.0, available("2026-07-01T00:00:00Z")) + .expect("pad"); + MAX_EVIDENCE_UNITS + 1 + ]; + assert_eq!( + execute(&request, &oversized, LagClock::EventTime, false, 2.0), + Err(AnalysisEngineError::LimitExceeded) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..1bfd1e064 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -58,6 +58,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); LineageWeave loopback contracts and request-bound terminal result are composed on the active product branch; production TLS remaining | partial | | executable cutoff-safe analysis runs | ADR 0012/0022; temporal research; API terminal-result contract | `analysis_engine` availability cutoff, snapshot binding, multiple-membership aggregation, digest-bound readiness artifact, and `tepp.trsl_topic_lineage.v1` execution through `topic_measurement`; synthetic recovery plus tamper/non-convergence tests and exact coverage on the active product branch | active-PR | +| irregular event-time analysis-run composition | ADR 0005/0022/0040; Voelkle et al. 2012 | `analysis_engine` `irregular_event_time_v1` binds `recover_event_series_mean_log_rate` and refuses pooled discrete lags; not DSEM and not implemented-main | active-PR | | immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest contract on protected main; `persistence_postgres` append-only SQL insert/lookup for `reproducibility_manifest`, `corpus_split_manifest`, `model_run`, and `model_artifact` (migration `0003`); full physical ERD constraints remaining | partial | | multilingual shared latent semantic space | PRD; ADR 0004; ADR 0020 | `semantic_core` span-grounded units (active-PR); concept dictionary and shared latent estimator remaining | active-PR | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | `topic_measurement` stable ALR/ILR coordinates and bounded CPU `f64` reference estimator on protected main; `model_selection` fitted candidate-`K` scoring on this PR; calibrated posterior promotion, method effects, persistence, and accelerated backends remaining | partial | diff --git a/docs/adr/0040-irregular-event-time-analysis-run.md b/docs/adr/0040-irregular-event-time-analysis-run.md new file mode 100644 index 000000000..bfc91bc73 --- /dev/null +++ b/docs/adr/0040-irregular-event-time-analysis-run.md @@ -0,0 +1,87 @@ +# ADR 0040 — Irregular event-time log-rate as an analysis-run output profile + +**Decision status:** Accepted +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0005 (ESEM/DSEM interpretation) and ADR 0022 (cutoff-safe analysis-run execution). +**Figma File ID:** N/A — this increment changes a Rust service crate and has no user-interface surface. +**Storybook inventory:** N/A — no reusable web object or interaction changed. + +## Context + +Protected main already recovers Voelkle, Oud, Davidov, and Schmidt (2012) exact +scalar local log-rates and maps discrete lags across unequal event intervals +inside `psychometric_core`. Operators still cannot request that composition as a +digest-bound analysis-run output. Recovery primitives alone are not the +ESEM/DSEM engine (GAP-006 / #169). A second Driver p.16 `std`-family restore, +Leiden consensus, GAP-003A scientific-acceptance wiring, or Compose persistence +would not close this operator-visible gap. + +Discrete autoregressive coefficients from unequal intervals are not one +coefficient. The licensed path is `a = ln(φ_src) / Δt_src` then +`φ_ref = exp(a Δt_ref)`. Pooling those discrete lags fails closed. + +## Decision + +Add the `irregular_event_time_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-mapped event-time scores plus `available_time`; +- excludes occasions whose availability is later than the request + `knowledge_cutoff`; +- invokes `recover_event_series_mean_log_rate` without reimplementing the + exponential map; +- maps the first eligible discrete lag onto a caller-supplied reference + interval through `map_discrete_lag_across_event_intervals`; +- invokes `refuse_pooled_discrete_lag_across_unequal_intervals` when the caller + asks to pool unequal intervals; +- invokes `claim_causal_effect` so temporal precedence cannot promote the + log-rate to a causal estimand; +- emits a canonical SHA-256-digested `tepp.irregular_event_time.v1` artifact + with occasion/interval counts, excluded-after-cutoff count, mean log-rate, + mapped reference lag, and inference status + `composed_interval_mapped_lags_not_dsem`; +- does not invent a DSEM sampler, persist rows, or claim strong invariance. + +This is scalar event-time composition, not DSEM, not RI-CLPM, and not a +random-effects sampler. + +## Alternatives considered + +1. Restore another Driver p.16 standardised matrix — rejected because those + recoveries are already a live micro-PR family and do not bind composition to + an analysis run. +2. Bind CWC-then-irregular residual log-rate — rejected because that psychometric + expose is already a live draft (#327) and is a different estimand. +3. Put interval mapping into `tepp_api` — rejected because transport contracts + and scientific composition would become one service boundary. +4. Bind the existing `psychometric_core` irregular event-time recovery to + ADR 0022's analysis-run profile — accepted. + +## Consequences + +Operators can request cutoff-safe irregular event-time log-rates as a +digest-bound terminal result. The artifact is not a causal effect, not a DSEM +fit, and not implemented-main until exact-head Checks and two independent +approvals land. Security and privacy are unchanged: scores are identity-free +mapped coordinates. Standalone/MSA boundaries stay with `analysis_engine`; no +cross-service table access is introduced. + +## Verification + +```text +cargo fmt -p analysis_engine -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +``` + +Known-truth noiseless scores `1.0`, `0.5`, `0.25` at event times `0`, `1`, `2` +recover mean log-rate `ln(0.5)` and mapped reference lag `0.25` at `Δt_ref = 2`. +Cutoff exclusion, snapshot/profile mismatch, pooled unequal intervals, non-event +clocks, empty eligibility, and oversize corpora fail closed. + +## Rollback and supersession + +Rollback removes the `irregular_event_time_v1` profile. No persisted schema +migration is introduced. Supersede only with an ADR that keeps per-interval +log-rate mapping distinct from pooled discrete lags and from DSEM. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..85667d6fd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,6 +30,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | +| [0040](0040-irregular-event-time-analysis-run.md) | Irregular event-time log-rate as an analysis-run profile | Accepted | active-PR | Complements ADR 0005/0022; maps discrete lags through `a` and refuses pooled unequal-interval coefficients. Not DSEM. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | diff --git a/docs/doctoring/irregular-event-time-analysis-run.md b/docs/doctoring/irregular-event-time-analysis-run.md new file mode 100644 index 000000000..e920d0316 --- /dev/null +++ b/docs/doctoring/irregular-event-time-analysis-run.md @@ -0,0 +1,15 @@ +# Irregular event-time analysis-run composition + +**Active slice:** ADR 0040 / `irregular_event_time_v1` +**Protected-main status:** not implemented-main + +`psychometric_core` already recovers Voelkle et al. (2012) local log-rates on +event time. This slice binds that recovery to a cutoff-safe analysis-run +profile so an operator can request a digest-bound terminal result. + +The executor maps discrete lags through `a` and refuses pooled coefficients +across unequal intervals. It is not DSEM, not a Driver p.16 `std`-family +restore, and not GAP-003A scientific-acceptance wiring. + +Exact-head Checks and two independent approvals are required before any +implemented-main claim.