From a054934d776e705d165ed1abffa4a0492a4cbfa2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 21:50:22 +0000 Subject: [PATCH 01/13] feat(analysis): bind independent TDT link-criterion fitting to an analysis-run profile GAP-004 leftover / ADR 0062. Bind existing fit_lineage_criterion_posteriors to cutoff-safe lineage_criterion_v1. Event-time draws remain producer evidence; date inference from record order is refused. --- CHANGELOG.md | 2 + crates/analysis_engine/src/lib.rs | 33 +- .../src/lineage_criterion_artifact.rs | 330 ++++++++++++++++++ .../lineage_criterion_execution_contract.rs | 197 +++++++++++ docs/TRACEABILITY.md | 1 + .../0062-lineage-criterion-analysis-run.md | 92 +++++ docs/adr/README.md | 2 + .../lineage-criterion-analysis-run.md | 18 + 8 files changed, 671 insertions(+), 4 deletions(-) create mode 100644 crates/analysis_engine/src/lineage_criterion_artifact.rs create mode 100644 crates/analysis_engine/tests/lineage_criterion_execution_contract.rs create mode 100644 docs/adr/0062-lineage-criterion-analysis-run.md create mode 100644 docs/doctoring/lineage-criterion-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..4672eb4f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- **Independent TDT link-criterion analysis-run profile**: cutoff-safe `lineage_criterion_v1` binds `fit_lineage_criterion_posteriors` and refuses date inference from record order (`analysis_engine`). Not a Bayesian sampler and not implemented-main. + - `event_core` adds bounded Allen interval-consistency classification, atomic path-consistency closure, contradiction/resource refusals, and an explicit dependency-error fallback without claiming unrestricted global satisfiability. - `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 2, p. 12 `MANIFESTTRAITVAR`; §7.1, p. 19; p. 16 `MANIFESTTRAITVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-27T14:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised manifest-trait variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 83–84). Table 2 names `MANIFESTTRAITVAR` `Ψ_τ` the additional time-invariant variance-covariance on the measurement level and sets it `NULL` when there is no manifest trait. Equation 5 writes `Γ ~ N(τ, Ψ)` and names that covariance the manifest traits. Section 7.1 names manifest traits stable individual differences in indicator levels, distinct from process-level `TRAITVAR` `φ_ξ`. Page 16 prints standardised matrices with the suffix `std` when appropriate. The printed example on p. 16 is `discreteDRIFTstd`, not `MANIFESTTRAITVARstd`. Footnote 4 standardises using only the relevant variance, not the total. The relevant variance for that named indicator-level correlation is `MANIFESTTRAITVAR`, not process-level `TRAITVAR` and not residual `MANIFESTVAR` `θ`. The 2017-era source forms `MANIFESTTRAITVARstd` only when `MANIFESTTRAITVAR != 0`, as `solve(sqrt(diag(MANIFESTTRAITVAR) + ridging)) %&% MANIFESTTRAITVAR` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. Unlike `TRAITVARstd`, that formation adds `diag(c(ridging), n.manifest)`. The default `ridging = FALSE` adds 0, not `0.0001`; that ridge is a numerical hack and is not this exact map. The scalar correlation is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR`. Form strictly positive `ψ` first, then `1 / √ψ`, then `(1 / √ψ) ψ (1 / √ψ)`. Unstandardised `MANIFESTTRAITVAR` is defined for a zero trait; standardised `MANIFESTTRAITVAR` is not. Zero `MANIFESTTRAITVAR` skips forming `MANIFESTTRAITVARstd` in the 2017-era source and fails closed here. Indicator-level trait variance is an event-time structural quantity, so a non-event clock fails closed. `MANIFESTTRAITVAR` does not require stable `a < 0`. Distinct positive `ψ` recover the same 1. `trait / trait = 1` is `TRAITVARstd` and recovers the same number and remains a distinct named quantity. `θ` is `MANIFESTVAR` and is measurement error, not this correlation. Meredith (1993) remains unread (web search 2026-08-27T14:20Z: Springer/Cambridge Core paywalled; Unpaywall historically `is_oa: false`; Springer `content/pdf` is an HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..0ff0992c3 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -8,10 +8,14 @@ //! 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. Independent TDT +//! link-criterion fitting is invoked through +//! [`fit_lineage_criterion_posteriors`] and does not infer a date from +//! record order. mod case_deletion_refit; mod lineage_criterion; +mod lineage_criterion_artifact; mod topic_context_posterior; mod topic_lineage_artifact; @@ -46,6 +50,13 @@ pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, fit_lineage_criterion_posteriors, }; +/// Independent TDT link-criterion artifact and execution contracts from this engine. +pub use lineage_criterion_artifact::{ + LINEAGE_CRITERION_ARTIFACT_BYTE_LIMIT, LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION, + LINEAGE_CRITERION_MODEL_CONTRACT_VERSION, LINEAGE_CRITERION_OUTPUT_PROFILE, + LineageCriterionArtifact, LineageCriterionExecution, LineageCriterionInput, + execute_lineage_criterion_run, +}; /// Bounded posterior topic-context producer contract and record types. pub use topic_context_posterior::{ TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, @@ -248,6 +259,10 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// A lineage-criterion artifact violated its bounded schema or counts. + InvalidLineageCriterionArtifact, + /// The independent TDT link-criterion fitter refused the observations. + LineageCriterionFitFailure, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +277,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::InvalidLineageCriterionArtifact => "invalid lineage-criterion artifact", + Self::LineageCriterionFitFailure => "lineage-criterion fitter refused the observations", }; formatter.write_str(message) } @@ -384,7 +401,7 @@ fn add_membership_count(sum: u64, membership_count: u32) -> Result Result<(), AnalysisEngineError> { @@ -394,7 +411,7 @@ fn require_receipt_identity( Ok(()) } -fn format_digest(digest: impl AsRef<[u8]>) -> String { +pub(crate) fn format_digest(digest: impl AsRef<[u8]>) -> String { let mut output = String::with_capacity(digest.as_ref().len() * 2); for byte in digest.as_ref() { let _ = write!(output, "{byte:02x}"); @@ -402,7 +419,7 @@ fn format_digest(digest: impl AsRef<[u8]>) -> String { output } -fn valid_identifier(value: &str) -> bool { +pub(crate) fn valid_identifier(value: &str) -> bool { !value.trim().is_empty() && value.len() <= MAX_ANALYSIS_IDENTIFIER_BYTES && !value.chars().any(char::is_control) @@ -681,6 +698,14 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidLineageCriterionArtifact, + "invalid lineage-criterion artifact", + ), + ( + AnalysisEngineError::LineageCriterionFitFailure, + "lineage-criterion fitter refused the observations", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/src/lineage_criterion_artifact.rs b/crates/analysis_engine/src/lineage_criterion_artifact.rs new file mode 100644 index 000000000..e891cac2a --- /dev/null +++ b/crates/analysis_engine/src/lineage_criterion_artifact.rs @@ -0,0 +1,330 @@ +//! Digest-bound independent TDT link-criterion fitting as an analysis-run profile. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::KnowledgeCutoff; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{ + AnalysisEngineError, LineageCriterionFitError, LineageCriterionObservation, + fit_lineage_criterion_posteriors, format_digest, require_receipt_identity, valid_identifier, +}; + +/// Versioned schema for a completed lineage-criterion artifact. +pub const LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION: &str = "tepp.lineage_criterion.v1"; +/// Model contract required by the lineage-criterion execution path. +pub const LINEAGE_CRITERION_MODEL_CONTRACT_VERSION: &str = "lineage_criterion_v1"; +/// Analysis-run output profile required for a lineage-criterion artifact. +pub const LINEAGE_CRITERION_OUTPUT_PROFILE: &str = "lineage_criterion_v1"; +/// Maximum canonical artifact JSON size. +pub const LINEAGE_CRITERION_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const LINEAGE_CRITERION_INFERENCE_STATUS: &str = + "independent_tdt_criterion_not_date_from_record_order"; + +/// Cutoff-safe independent TDT link-criterion payload bound to the existing fitter. +#[derive(Clone, Debug)] +pub struct LineageCriterionInput<'a> { + observations: &'a [LineageCriterionObservation], + draw_count: usize, +} + +impl<'a> LineageCriterionInput<'a> { + /// Construct a lineage-criterion payload from existing fitter inputs. + #[must_use] + pub const fn new(observations: &'a [LineageCriterionObservation], draw_count: usize) -> Self { + Self { + observations, + draw_count, + } + } + + /// Borrow the independently observed exact pairs. + #[must_use] + pub const fn observations(&self) -> &'a [LineageCriterionObservation] { + self.observations + } + + /// Return the common temporal-draw count carried without alteration. + #[must_use] + pub const fn draw_count(&self) -> usize { + self.draw_count + } +} + +/// Completed, bounded independent TDT link-criterion counts for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LineageCriterionArtifact { + /// 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 run. + pub knowledge_cutoff: String, + /// Number of independently observed exact pairs. + pub pair_count: u64, + /// Common temporal-draw count carried without alteration. + pub draw_count: u64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl LineageCriterionArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidLineageCriterionArtifact`] when + /// the schema, identifiers, counts, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > LINEAGE_CRITERION_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidLineageCriterionArtifact)?; + 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() > LINEAGE_CRITERION_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 != LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.pair_count == 0 + || self.draw_count == 0 + || self.inference_status != LINEAGE_CRITERION_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidLineageCriterionArtifact); + } + Ok(()) + } +} + +/// One completed lineage-criterion artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct LineageCriterionExecution { + /// Digest-bound completed lineage-criterion artifact. + pub artifact: LineageCriterionArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute independent TDT link-criterion fitting as one analysis-run profile. +/// +/// The executor invokes [`fit_lineage_criterion_posteriors`] and does not +/// reimplement Jeffreys fitting, infer a date from record order, or promote +/// CHRONOS predictions to observed facts. Raw posteriors stay with the +/// scientific fitter; the operator artifact carries only bounded pair and +/// draw counts. Event-time draws remain producer evidence. This is not a +/// Bayesian sampler, not GPU execution, and not topic birth/split/merge. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, invalid +/// observations, fitter refusal, or invalid artifact error. +pub fn execute_lineage_criterion_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + input: &LineageCriterionInput<'_>, + 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 != LINEAGE_CRITERION_MODEL_CONTRACT_VERSION + || request.output_profile != LINEAGE_CRITERION_OUTPUT_PROFILE + || input.draw_count() == 0 + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let fits = fit_lineage_criterion_posteriors(input.observations(), input.draw_count()).map_err( + |error| match error { + LineageCriterionFitError::EmptyInput + | LineageCriterionFitError::InvalidPairIdentity + | LineageCriterionFitError::TemporalDrawMismatch => { + AnalysisEngineError::InvalidEvidence + } + LineageCriterionFitError::Criterion(_) => { + AnalysisEngineError::LineageCriterionFitFailure + } + }, + )?; + let pair_count = + u64::try_from(fits.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let draw_count = + u64::try_from(input.draw_count()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let artifact = LineageCriterionArtifact { + schema_version: LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + pair_count, + draw_count, + inference_status: LINEAGE_CRITERION_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "lineage_criterion", + pair_count, + 2, + LINEAGE_CRITERION_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("lineage_criterion_artifact_{}", &digest[..16]), + digest, + LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(LineageCriterionExecution { + artifact, + terminal_result, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + LINEAGE_CRITERION_ARTIFACT_BYTE_LIMIT, LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION, + LINEAGE_CRITERION_INFERENCE_STATUS, LineageCriterionArtifact, LineageCriterionInput, + }; + use crate::{AnalysisEngineError, LineageCriterionObservation}; + + fn artifact() -> LineageCriterionArtifact { + LineageCriterionArtifact { + schema_version: LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + pair_count: 2, + draw_count: 32, + inference_status: LINEAGE_CRITERION_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &LineageCriterionArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidLineageCriterionArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + LineageCriterionArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + LineageCriterionArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidLineageCriterionArtifact) + ); + assert_eq!( + LineageCriterionArtifact::from_json( + &"x".repeat(LINEAGE_CRITERION_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.pair_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.draw_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.inference_status.clear(); + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } + + #[test] + fn input_accessors_expose_observations_and_draw_count() { + let observations = [LineageCriterionObservation { + pair_id: "pair-a".into(), + successes: 1, + trials: 2, + predecessor_event_time_draws: vec!["2026-01-01T00:00:00Z".into(); 32], + successor_event_time_draws: vec!["2026-01-02T00:00:00Z".into(); 32], + }]; + let input = LineageCriterionInput::new(&observations, 32); + assert_eq!(input.observations(), &observations); + assert_eq!(input.draw_count(), 32); + } +} diff --git a/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs b/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs new file mode 100644 index 000000000..8bfbd6e6a --- /dev/null +++ b/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs @@ -0,0 +1,197 @@ +//! End-to-end contract for cutoff-safe independent TDT link-criterion fitting. + +use analysis_engine::{ + AnalysisEngineError, LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION, + LINEAGE_CRITERION_MODEL_CONTRACT_VERSION, LINEAGE_CRITERION_OUTPUT_PROFILE, + LineageCriterionInput, LineageCriterionObservation, execute_lineage_criterion_run, +}; +use temporal_core::KnowledgeCutoff; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn observation(pair_id: &str, successes: u32, trials: u32) -> LineageCriterionObservation { + LineageCriterionObservation { + pair_id: pair_id.into(), + successes, + trials, + predecessor_event_time_draws: vec!["2026-01-01T00:00:00Z".into(); 32], + successor_event_time_draws: vec!["2026-01-02T00:00:00Z".into(); 32], + } +} + +fn observations() -> Vec { + vec![ + observation("pair-a", 1_000, 10_000), + observation("pair-b", 5_000, 10_000), + ] +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "lineage-criterion-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-lineage-criterion".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: LINEAGE_CRITERION_MODEL_CONTRACT_VERSION.into(), + output_profile: LINEAGE_CRITERION_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-lineage-criterion", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn execute( + request: &AnalysisRunRequest, +) -> Result { + let observations = observations(); + execute_lineage_criterion_run( + request, + &accepted(request), + "snapshot-lineage-criterion", + cutoff(), + &LineageCriterionInput::new(&observations, 32), + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn identified_pairs_emit_digest_bound_counts_without_inferring_dates() { + let request = request(); + let execution = execute(&request).expect("execution"); + assert_eq!( + execution.artifact.schema_version, + LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.pair_count, 2); + assert_eq!(execution.artifact.draw_count, 32); + assert_eq!( + execution.artifact.inference_status, + "independent_tdt_criterion_not_date_from_record_order" + ); + 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(LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[test] +fn invalid_observations_and_criterion_refusal_fail_closed() { + let request = request(); + assert_eq!( + execute_lineage_criterion_run( + &request, + &accepted(&request), + "snapshot-lineage-criterion", + cutoff(), + &LineageCriterionInput::new(&[], 32), + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + let observations = observations(); + assert_eq!( + execute_lineage_criterion_run( + &request, + &accepted(&request), + "snapshot-lineage-criterion", + cutoff(), + &LineageCriterionInput::new(&observations, 0), + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + let invalid = vec![observation("pair-a", 3, 2)]; + assert_eq!( + execute_lineage_criterion_run( + &request, + &accepted(&request), + "snapshot-lineage-criterion", + cutoff(), + &LineageCriterionInput::new(&invalid, 32), + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::LineageCriterionFitFailure) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + let observations = observations(); + assert_eq!( + execute_lineage_criterion_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + &LineageCriterionInput::new(&observations, 32), + "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 = "case_deletion_refit_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "composed_fitted_lineage_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "fitted_candidate_k_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "pareto_candidate_k_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "trsl_topic_lineage_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "method_effects_v1".into(); + value + }, + ] { + assert_eq!( + execute(&invalid_request), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..31d13e2f1 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -74,6 +74,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; `modality_source` modality-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `corpus_background` background-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `prompt_source` prompt-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | +| independent TDT link-criterion analysis-run | ADR 0022/0023/0062 | `analysis_engine` `lineage_criterion_v1` binds `fit_lineage_criterion_posteriors`; event-time draws remain producer evidence; refuses date inference from record order; not a Bayesian sampler and not implemented-main | active-PR | | candidate K statistical/Pareto gates | ADR 0012; research | `model_selection` fits each candidate `K` with the CPU `f64` reference and scores the actual mixture likelihood plus Schwarz's (1978) `ℓ − (p ln N)/2` penalty before the Pareto gate; candidate blinding, blinded LLM review, GPU, and backend comparison remain accepted-target | active-PR | | compositional topic correlation / stable clustering | ADR 0005/0012; research | future `network_analysis` | accepted-target | | posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | `psychometric_core` construct/input gates, true-loading OLS recovery, posterior-draw point-estimate averaging, Rubin `T` on draw-level OLS loadings, CWC within/between OLS plus the contextual effect, event-time log-rate, constant- and time-varying-predictor discrete effects (Voelkle Eqs. 12 and 14), exact scalar discrete process noise (Driver et al., 2017, Eq. 3), lagged latent covariance and unconditional latent variance (Driver et al., 2017, Eq. 3–4), stationary within-subject variance (Driver et al., 2017, Eq. 4 as `Δt → ∞`; `asymDIFFUSION`), trait-plus-state variance (Driver et al., 2017, §4.3 `TRAITVAR`; not process noise), observed-indicator variance and lagged observed covariance (Driver et al., 2017, Eq. 5; Table 2 `MANIFESTVAR` is `Θ`, not `Var(y)`; `MANIFESTTRAITVAR` is not `MANIFESTVAR`; `Θ` does not enter lagged observed covariance; observed-indicator mean is `τ + λ μ`; `MANIFESTMEANS` is not `E(y)`; `CINT` is not `MANIFESTMEANS`; discrete latent mean is `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`; `T0MEANS` is not `μ_t`; evolved observed mean is `τ + λ μ_t`; `τ + λ μ_0` is not `E(y_t)`; contemporaneous `TDPREDEFFECT` impulse is `m x`, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that contemporaneous impulse is `τ + λ(μ_t + m x)`, and `τ + λ μ_t` is not that observed mean; time-independent `TIPREDEFFECT` increment is `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, not `M x`, not Voelkle Eq. 14, and not the coefficient `B`; Eq. 5 of that increment is `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; within-interval `TDPREDEFFECT` carry is `e^{A(t−u)} M x` for `t0 < u < t`, not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that carry is `τ + λ(μ_t + e^{a(t−u)} m x)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that carried observed mean when `u ≠ t`; §7.2 level-change `CINT` is `κ = −a m x` (`a < 0`; not the dissipating Dirac, not a free `CINT`, not `TIPREDEFFECT`; Eq. 3 of that setting is `(1 − e^{a Δt}) m x`); §7.2 extra-process contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, not the dissipating Dirac; `ε ≥ 0` fails closed; Eq. 5 of that contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`; extra `LAMBDA` is 0; `τ + λ μ_t` is not that observed mean; after-t0 extra-process `TDPREDEFFECT` uses `t − u` with `t0 < u < t` while `μ_t` uses `Δt`; that after-t0 observed mean is not the first-occasion extra-process observed mean; §7.2 `asymTIPREDEFFECT` is `-B z / a` for `a < 0` and is not `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`; §7.2 `addedTIPREDVAR` is `(B / a)² v` and is not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`; Table 2 `asymCINT` is `-κ / a` for `a < 0` and is not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`; p. 16 stationary `T0MEANS` is `-κ / a + −B z / a` and is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean; Eq. 5 of that constrained mean is `τ + λ(−κ / a + −B z / a)`; `τ + λ μ_0` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`; stationary `T0VAR` is `trait + −q / (2 a) + (B / a)² v` (not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Eq. 5 of that constrained variance is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`)); lagged stationary `T0VAR` is `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (trait and `addedTIPREDVAR` do not decay; contemporaneous `T0VAR` is not that lagged map; decaying the constrained total as if it were all state is not that lagged map; Eq. 5 of that lagged covariance is `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`; `Θ` does not enter; contemporaneous `Var(y_0)` is not that lagged observed covariance; the lagged latent covariance is not that observed covariance); later-occasion stationary `T0VAR` is `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`; evolving the constrained total as if it were all state is not that later map; the lagged covariance omits `Q_Δt`; `Q_Δt` is not that later map; Eq. 5 of that later-occasion variance is `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`; lagged observed covariance omits `Q_Δt` and `θ`; `MANIFESTVAR` is not `Var(y_t)`; the later-occasion latent variance is not `Var(y_t)`); predetermined later-occasion `T0VAR` is `trait + e^{2 a Δt} p_0 + Q_Δt + (B / a)² v` (free `T0VAR` `p_0` is not that later map; setting `p_0 = −q / (2 a)` recovers the stationary later-occasion map; stationary later variance uses `−q / (2 a)` in place of `p_0` and is not that later map when `p_0` is free; evolving `trait + p_0 + (B / a)² v` as if it were all state is not that later map; Eq. 5 of that predetermined later-occasion variance is `λ²(trait + e^{2 a Δt} p_0 + Q_Δt + (B / a)² v) + θ + ψ`; `MANIFESTVAR` is not `Var(y_t)`; the predetermined later-occasion latent variance is not `Var(y_t)`; stationary later observed variance is not that observed variance when `p_0` is free); predetermined lagged `T0VAR` is `trait + e^{a Δt} p_0 + (B / a)² v` (free `T0VAR` `p_0` is not that lagged map; setting `p_0 = −q / (2 a)` recovers the stationary lagged map; stationary lagged covariance uses `−q / (2 a)` in place of `p_0` and is not that lagged map when `p_0` is free; evolving `trait + p_0 + (B / a)² v` as if it were all state is not that lagged map; later-occasion variance includes `Q_Δt` and is not that lagged map; Eq. 5 of that predetermined lagged covariance is `λ²(trait + e^{a Δt} p_0 + (B / a)² v) + ψ`; `MANIFESTVAR` does not enter; the predetermined lagged latent covariance is not that observed covariance; predetermined later observed variance includes `Q_Δt` and `θ` and is not that lagged observed covariance; stationary lagged observed covariance is not that observed covariance when `p_0` is free; the predetermined first-occasion variance of §4.3 predetermined `T0VAR` is `trait + p_0 + (B / a)² v`; free `p_0` is not that map; stationary first-occasion variance uses `−q / (2 a)` in place of `p_0` and is not that map when `p_0` is free; lagged covariance decays the state and is not that map; later-occasion variance includes `Q_Δt` and is not that map; Eq. 5 of that predetermined first-occasion variance is `λ²(trait + p_0 + (B / a)² v) + θ + ψ`; `MANIFESTVAR` is not that first-occasion observed variance; the predetermined first-occasion latent variance is not that observed variance; stationary first-occasion observed variance is not that observed variance when `p_0` is free; predetermined later observed variance includes `Q_Δt` and is not that first-occasion observed variance; later-start lagged covariance of predetermined `T0VAR` is `trait + e^{a s}(e^{2 a u} p_0 + Q_u) + (B / a)² v` (Driver et al., 2017, §4.3 `startoffset`; Eq. 4; JSS PDF re-opened 2026-08-23T10:27Z; first-occasion lagged omits `e^{a s} Q_u`; later-occasion variance does not lag; stationary lagged uses `−q / (2 a)`; decaying the later total is not that map; Eq. 5 of that later-start lagged covariance is `λ²` of it plus `ψ`; `Θ` does not enter; first-occasion lagged observed omits `e^{a s} Q_u`; later observed variance includes `Q_u` and `θ`; later-start later-occasion variance of predetermined `T0VAR` is `trait + e^{2 a s}(e^{2 a u} p_0 + Q_u) + Q_s + (B / a)² v` (Driver et al., 2017, §4.3 `startoffset`; Eq. 3–4 Chapman–Kolmogorov `Q_{u+s} = e^{2 a s} Q_u + Q_s`; JSS PDF re-opened 2026-08-23T11:05Z; later-occasion variance at `u` omits `Q_s`; later-start lagged covariance omits `Q_s`; stationary later uses `−q / (2 a)`; evolving the later total as if it were all state is not that map; ignoring `startoffset` omits `e^{2 a s} Q_u`; Eq. 5 of that later-start later-occasion variance is `λ²` of it plus `θ + ψ`; `MANIFESTVAR` is not that observed variance; p. 16 `discreteDRIFTstd` is `e^{a Δt}` after strictly positive `asymDIFFUSION` `-q / (2 a)` (footnote 4; unstandardised `e^{a Δt}` is defined for growing `a ≥ 0` and for zero diffusion and is not `discreteDRIFTstd`; the §7.1 trait-plus-state autocorrelation uses `TRAITVAR` and is not `discreteDRIFTstd`; p. 16 `discreteDIFFUSIONstd` is `Q_Δt / (−q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` (footnote 4; unstandardised `Q_Δt` is defined for growing `a ≥ 0` and for zero diffusion and is not `discreteDIFFUSIONstd`; the continuous standardisation `−2 a` is not `discreteDIFFUSIONstd`; `Q_Δt / (trait + p + added)` uses `TRAITVAR` and is not `discreteDIFFUSIONstd`; `TRAITVAR` is not the standardisation variance; p. 16 `DIFFUSIONstd` is `q / (−q / (2 a)) = −2 a` after strictly positive `asymDIFFUSION` `-q / (2 a)` (Driver et al., 2017, p. 16; Eq. 4; footnote 4; JSS PDF re-opened 2026-08-23T13:20Z; unstandardised `q` is defined for growing `a ≥ 0` and for zero diffusion and is not `DIFFUSIONstd`; the discrete standardisation `Q_Δt / (−q / (2 a))` depends on `Δt` and is not `DIFFUSIONstd`; `q / (trait + p + added)` uses `TRAITVAR` and is not `DIFFUSIONstd`; `TRAITVAR` is not the standardisation variance; p. 16 `DRIFTstd` is the continuous auto-effect after strictly positive `asymDIFFUSION` `-q / (2 a)` (Driver et al., 2017, p. 16; Eq. 1; footnote 4; JSS PDF re-opened 2026-08-23T13:28Z); unstandardised `a` is defined for growing `a ≥ 0` and for zero diffusion and is not `DRIFTstd`; the discrete standardisation `e^{a Δt}` depends on the event interval and is not `DRIFTstd`; `a p / (trait + p + added)` uses `TRAITVAR` and is not `DRIFTstd`; `TRAITVAR` is not the standardisation variance); p. 16 `asymTIPREDEFFECTstd` is `(-B / a) · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` and strictly positive predictor variance `v` (Driver et al., 2017, p. 16; §7.2; footnote 4; JSS PDF re-opened 2026-08-23T14:25Z; unstandardised `-B / a` is defined for a zero coefficient and for zero predictor variance and is not `asymTIPREDEFFECTstd`; the finite-interval standardisation `A^{-1}[e^{A Δt} − I] B · √v / √p` depends on the event interval and is not `asymTIPREDEFFECTstd`; `(-B / a) · √v / √(trait + p + added)` uses `TRAITVAR` and is not `asymTIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); p. 16 `TIPREDEFFECTstd` is `B · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` and strictly positive predictor variance `v` (Driver et al., 2017, p. 16; §7.2; footnote 4; JSS PDF re-opened 2026-08-23T16:21Z; unstandardised `B` is defined for a zero coefficient and for zero predictor variance and is not `TIPREDEFFECTstd`; the asymptotic standardisation `(-B / a) · √v / √p` is the total change and is not `TIPREDEFFECTstd`; the finite-interval standardisation `A^{-1}[e^{A Δt} − I] B · √v / √p` depends on the event interval and is not `TIPREDEFFECTstd`; `B · √v / √(trait + p + added)` uses `TRAITVAR` and is not `TIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); Table 3 `T0TIPREDEFFECTstd` is `t0_b · √v / √p_0` after strictly positive free `T0VAR` `p_0` and strictly positive predictor variance `v` (Driver et al., 2017, Table 3, p. 13; p. 16; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T17:20Z; the affected variance is free `T0VAR`, not `asymDIFFUSION`; unstandardised `t0_b` is defined for a zero coefficient and for zero predictor variance and is not `T0TIPREDEFFECTstd`; `TIPREDEFFECTstd` `B · √v / √(-q / (2 a))` is the continuous coefficient and is not `T0TIPREDEFFECTstd`; `asymTIPREDEFFECTstd` `(-B / a) · √v / √p` is the total change and is not `T0TIPREDEFFECTstd`; `t0_b · √v / √(trait + p_0 + added)` uses `TRAITVAR` and is not `T0TIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); 2017-era `addedT0TIPREDVAR` is `t0_b² v` (Driver et al., 2017, Table 3, p. 13; p. 16; §7.2; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T18:20Z; `T0TIPREDEFFECT %*% TIPREDVAR %*% t(T0TIPREDEFFECT)` immediately after `T0TIPREDEFFECTstd`; form `t0_b` first, then square, then multiply by `v`; a zero coefficient or zero predictor variance is exactly zero; free `T0TIPREDEFFECT` does not require `a < 0`; `(B / a)² v` is `addedTIPREDVAR` and is not this first-occasion map; `t0_b · √v / √p_0` is `T0TIPREDEFFECTstd` and is not this variance; free `T0VAR` is not this extra TI variance; `TRAITVAR` is not this extra TI variance; Equation 5 of 2017-era `addedT0TIPREDVAR` is `λ² t0_b² v` (Driver et al., 2017, Eq. 5, p. 5; Table 3, p. 13; Table 2, p. 12; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T19:10Z; form `t0_b² v` first, then `(λ extra) λ` with `θ = 0`; a zero loading or zero extra is exactly zero; `t0_b² v` is the latent extra, not the observed extra; `λ² p_0 + θ` is first-occasion observed variance, not this extra; `λ² (B / a)² v` is Eq. 5 of `addedTIPREDVAR`, not this first-occasion observed extra; `MANIFESTVAR` `θ` is not this extra; Equation 5 of §7.2 `addedTIPREDVAR` is `λ² (B / a)² v`; form `(B / a)² v` first, then `(λ extra) λ` with `θ = 0`; a zero loading or zero extra is exactly zero; lasting asymptotic extra requires `a < 0`; `(B / a)² v` is the latent extra, not the observed extra; `λ² t0_b² v` is first-occasion extra observed TI variance, not this extra; `λ² p + θ` is stationary observed variance, not this extra; `MANIFESTVAR` `θ` is not this extra; p. 16 `TDPREDEFFECTstd` is `m · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` and strictly positive time-dependent predictor variance; unstandardised `M` is not `TDPREDEFFECTstd`; `TIPREDEFFECTstd` is not `TDPREDEFFECTstd` even when `M = B`; intercept-style `A^{-1}[e^{A Δt} − I] M · √v / √p` is not `TDPREDEFFECTstd`; `m · √v / √(trait + p + added)` uses `TRAITVAR` and is not `TDPREDEFFECTstd`; Table 3 / p. 16 `T0TDPREDEFFECTstd` is `t0_m · √v / √p_0` after strictly positive free `T0VAR` and strictly positive TD predictor variance; unstandardised `t0_m` is not `T0TDPREDEFFECTstd`; `TDPREDEFFECTstd` uses `asymDIFFUSION` and is not `T0TDPREDEFFECTstd`; `T0TIPREDEFFECTstd` is not `T0TDPREDEFFECTstd` even when `t0_m = t0_b`; `t0_m · √v / √(trait + p_0 + added)` uses `TRAITVAR` and is not `T0TDPREDEFFECTstd`; free `T0VAR` does not require `a < 0`; p. 16 `T0VARstd` is `p_0 / p_0 = 1` after strictly positive free `T0VAR` (`solve(sqrt(diag(T0VAR))) %&% T0VAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; default ridge is 0); unstandardised `T0VAR` is not `T0VARstd`; `T0TDPREDEFFECTstd` is not `T0VARstd`; `addedT0TIPREDVAR` is not `T0VARstd`; p. 16 `TRAITVARstd` is `trait / trait = 1` after strictly positive `TRAITVAR` (`solve(sqrt(diag(TRAITVAR))) %&% TRAITVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; no ridge addend); unstandardised `TRAITVAR` is not `TRAITVARstd`; `T0VARstd` is not `TRAITVARstd` even when both equal 1; `addedT0TIPREDVAR` is not `TRAITVARstd`; p. 16 `MANIFESTTRAITVARstd` is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR` (`solve(sqrt(diag(MANIFESTTRAITVAR))) %&% MANIFESTTRAITVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0); unstandardised `MANIFESTTRAITVAR` is not `MANIFESTTRAITVARstd`; `TRAITVARstd` is not `MANIFESTTRAITVARstd` even when both equal 1; `MANIFESTVAR` is not `MANIFESTTRAITVARstd`; p. 16 `MANIFESTVARstd` is `θ / θ = 1` after strictly positive `MANIFESTVAR` (`solve(sqrt(diag(MANIFESTVAR))) %&% MANIFESTVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; 2017-era `dimnames` assignment to `latentNames` is a source bug); unstandardised `MANIFESTVAR` is not `MANIFESTVARstd`; `MANIFESTTRAITVARstd` is not `MANIFESTVARstd` even when both equal 1; Equation 5 `Var(y)` is not `MANIFESTVARstd`; p. 16 `TIPREDVARstd` is `v / v = 1` after strictly positive `TIPREDVAR` (`solve(sqrt(diag(TIPREDVAR))) %&% TIPREDVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; `dimnames` are `TIpredNames`); unstandardised `TIPREDVAR` is not `TIPREDVARstd`; `MANIFESTVARstd` is not `TIPREDVARstd` even when both equal 1; §7.2 `addedTIPREDVAR` is not `TIPREDVARstd`; p. 16 `asymDIFFUSIONstd` is `p / p = 1` after strictly positive `asymDIFFUSION` (`solve(sqrt(diag(asymDIFFUSION))) %&% asymDIFFUSION`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; `dimnames` are `latentNames`); unstandardised `asymDIFFUSION` is not `asymDIFFUSIONstd`; `TIPREDVARstd` is not `asymDIFFUSIONstd` even when both equal 1; `DIFFUSIONstd` `−2 a` is not `asymDIFFUSIONstd`; p. 16 `discreteCINTstd` is `A^{-1}[e^{A Δt} − I] κ / √p` after strictly positive `asymDIFFUSION`; unstandardised `discreteCINT` is not `discreteCINTstd`; `κ / √p` is not `discreteCINTstd`; `(-κ / a) / √p` is not `discreteCINTstd`; `asymCINTstd` is `(-κ / a) / √p` after strictly positive `asymDIFFUSION`; unstandardised `asymCINT` is not `asymCINTstd`; `κ / √p` is not `asymCINTstd`; `discreteCINTstd` is not `asymCINTstd`; `T0MEANSstd` is `μ_0 / √p_0` after strictly positive free `T0VAR`; unstandardised `T0MEANS` is not `T0MEANSstd`; `T0VARstd` is not `T0MEANSstd`; `μ_0 / √asymDIFFUSION` is not `T0MEANSstd`; `MANIFESTMEANSstd` is `τ / √θ` after strictly positive `MANIFESTVAR`; unstandardised `MANIFESTMEANS` is not `MANIFESTMEANSstd`; `MANIFESTVARstd` is not `MANIFESTMEANSstd`; `τ / √(λ² Var(η) + θ)` is not `MANIFESTMEANSstd`; p. 16 `CINTstd` is `κ / √p` after strictly positive `asymDIFFUSION`; unstandardised `CINT` is not `CINTstd`; `asymCINTstd` is not `CINTstd`; `discreteCINTstd` is not `CINTstd`; `κ / √(trait + p + added)` is not `CINTstd`;))))), irregular already-centered residual lag, and strong/strict-gated latent means on the stacked psychometric PR (two-observation residual variance is identically `0` and caps at strong/scalar; Putnick & Bornstein, 2016, PMC5145197 opened 2026-08-19T22:15Z); full ESEM/DSEM remaining | partial | diff --git a/docs/adr/0062-lineage-criterion-analysis-run.md b/docs/adr/0062-lineage-criterion-analysis-run.md new file mode 100644 index 000000000..755437669 --- /dev/null +++ b/docs/adr/0062-lineage-criterion-analysis-run.md @@ -0,0 +1,92 @@ +# ADR 0062 — Independent TDT link-criterion fitting 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 0023 (lineage-criterion anchor) 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 fits independently observed TDT link-criterion +Jeffreys posteriors inside +`analysis_engine::fit_lineage_criterion_posteriors`. Event-time draws remain +producer evidence from the temporal model; the fitter does not infer a date +from record order or promote CHRONOS predictions to observed facts. +Operators still cannot request that runner as a digest-bound analysis-run +output. + +Method-effect labels, template-copy refusals, house-voice refusals, +prompt-boilerplate refusals, non-lexical modality refusals, exhaustive +case-deletion, composed fitted-lineage, Pareto candidate-`K`, and topic +activity remain different profiles. Full Bayesian sampling, GPU, and topic +birth/split/merge remain later GAP-004 work and are not this slice. ADR +0058 through ADR 0061 are already taken by live sibling PRs. + +## Decision + +Add the `lineage_criterion_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-constructed `LineageCriterionObservation` values and a + common draw count; +- requires the request snapshot and knowledge cutoff to match the offered + construction; +- invokes `fit_lineage_criterion_posteriors` without reimplementing Jeffreys + fitting; +- emits a canonical SHA-256-digested `tepp.lineage_criterion.v1` artifact + with pair count, draw count, and inference status + `independent_tdt_criterion_not_date_from_record_order`; +- keeps raw posteriors and pair identities with the scientific fitter rather + than copying them onto the operator artifact; +- refuses reuse of `case_deletion_refit_v1`, `composed_fitted_lineage_v1`, + `fitted_candidate_k_v1`, `pareto_candidate_k_v1`, `trsl_topic_lineage_v1`, + and `method_effects_v1` as this profile; +- does not invent a Bayesian sampler, persist rows, select GPU backends, + infer dates from record order, or emit topic birth/split/merge. + +This is independent TDT link-criterion fitting, not a date inference and +not a posterior sampler. + +## Alternatives considered + +1. Bind another method-effect, case-deletion, or composed-lineage profile — + rejected because those binds are already live as separate analysis-run + profiles. +2. Invent a Bayesian sampler or topic birth/split/merge engine — rejected + because those functions do not exist on protected main. +3. Copy raw posteriors or pair identities onto the operator artifact — + rejected because the fitter owns posterior meaning and the analysis-run + contract stays identity-free and bounded. +4. Bind the existing independent-criterion runner to ADR 0022's + analysis-run profile — accepted. + +## Consequences + +Operators can request cutoff-safe independent TDT link-criterion fitting as +a digest-bound terminal result. The artifact does not claim date inference +from record order, CHRONOS promotion, Bayesian sampling, GPU parity, or +topic birth/split/merge. Snapshot/profile/cutoff mismatch, invalid +observations, and fitter refusal fail closed. + +## Verification + +The PR includes Rust unit and integration tests for successful pair/draw +counts, empty or invalid observations, criterion refusal, snapshot/profile/ +cutoff mismatch including reuse of live sibling profiles, and artifact +tampering. Run: + +```text +cargo fmt --all -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +python3 scripts/validate_documentation.py +``` + +## Rollback and supersession + +Rollback removes the `lineage_criterion_v1` profile. No persisted schema +migration is introduced. Supersede only with an ADR that keeps independent +TDT link-criterion fitting distinct from date inference, CHRONOS +promotion, and Bayesian sampling. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..b09d2bfd1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,6 +28,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0020](0020-span-grounded-semantic-units.md) | Span-grounded semantic units; language tags are not identity | Accepted | active-PR | First ADR 0004 production slice; concept alignment, invariance, and topic estimation are not claimed. | | [0021](0021-lineageweave-project-history-boundary.md) | LineageWeave project-history service boundary | Accepted | active-PR | Credential-free bounded project-history API preserves LineageWeave authorization ownership. | | [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. | +| [0062](0062-lineage-criterion-analysis-run.md) | Independent TDT link-criterion as an analysis-run profile | Accepted | active-PR | Complements ADR 0022/0023; independent criterion fitting, not date inference and not a Bayesian sampler. | | [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. | | [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. | @@ -138,6 +139,7 @@ Use the narrowest owning ADR when decisions overlap: - **project-history wire-size symmetry:** ADR 0019. - **LineageWeave project-history service boundary:** ADR 0021. - **accepted-run execution and terminal artifact production:** ADR 0022. +- **independent TDT link-criterion analysis-run claim boundary:** ADR 0062. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/lineage-criterion-analysis-run.md b/docs/doctoring/lineage-criterion-analysis-run.md new file mode 100644 index 000000000..6a701cee2 --- /dev/null +++ b/docs/doctoring/lineage-criterion-analysis-run.md @@ -0,0 +1,18 @@ +# Independent TDT link-criterion analysis-run composition + +**Active slice:** ADR 0062 / `lineage_criterion_v1` +**Protected-main status:** not implemented-main + +`analysis_engine` already fits independently observed TDT link-criterion +Jeffreys posteriors through `fit_lineage_criterion_posteriors`. Event-time +draws remain producer evidence. This slice binds that runner to a +cutoff-safe analysis-run profile so an operator can request a digest-bound +terminal result. + +The executor does not infer a date from record order and does not promote +CHRONOS predictions to observed facts. Raw posteriors stay with the +scientific fitter. It is not a Bayesian sampler, not GPU execution, and not +topic birth/split/merge. + +Exact-head Checks and two independent approvals are required before any +implemented-main claim. From 1628f1f7b154e8b0287edf14369b3cf2d73f1958 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 21:50:59 +0000 Subject: [PATCH 02/13] docs(adr): retarget lineage-criterion analysis-run to ADR 0063 ADR 0062 is already taken by live TEPP #422 corpus-background refusals. --- docs/TRACEABILITY.md | 2 +- ...analysis-run.md => 0063-lineage-criterion-analysis-run.md} | 2 +- docs/adr/README.md | 4 ++-- docs/doctoring/lineage-criterion-analysis-run.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename docs/adr/{0062-lineage-criterion-analysis-run.md => 0063-lineage-criterion-analysis-run.md} (98%) diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 31d13e2f1..a3d7c5393 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -74,7 +74,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; `modality_source` modality-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `corpus_background` background-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `prompt_source` prompt-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | -| independent TDT link-criterion analysis-run | ADR 0022/0023/0062 | `analysis_engine` `lineage_criterion_v1` binds `fit_lineage_criterion_posteriors`; event-time draws remain producer evidence; refuses date inference from record order; not a Bayesian sampler and not implemented-main | active-PR | +| independent TDT link-criterion analysis-run | ADR 0022/0023/0063 | `analysis_engine` `lineage_criterion_v1` binds `fit_lineage_criterion_posteriors`; event-time draws remain producer evidence; refuses date inference from record order; not a Bayesian sampler and not implemented-main | active-PR | | candidate K statistical/Pareto gates | ADR 0012; research | `model_selection` fits each candidate `K` with the CPU `f64` reference and scores the actual mixture likelihood plus Schwarz's (1978) `ℓ − (p ln N)/2` penalty before the Pareto gate; candidate blinding, blinded LLM review, GPU, and backend comparison remain accepted-target | active-PR | | compositional topic correlation / stable clustering | ADR 0005/0012; research | future `network_analysis` | accepted-target | | posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | `psychometric_core` construct/input gates, true-loading OLS recovery, posterior-draw point-estimate averaging, Rubin `T` on draw-level OLS loadings, CWC within/between OLS plus the contextual effect, event-time log-rate, constant- and time-varying-predictor discrete effects (Voelkle Eqs. 12 and 14), exact scalar discrete process noise (Driver et al., 2017, Eq. 3), lagged latent covariance and unconditional latent variance (Driver et al., 2017, Eq. 3–4), stationary within-subject variance (Driver et al., 2017, Eq. 4 as `Δt → ∞`; `asymDIFFUSION`), trait-plus-state variance (Driver et al., 2017, §4.3 `TRAITVAR`; not process noise), observed-indicator variance and lagged observed covariance (Driver et al., 2017, Eq. 5; Table 2 `MANIFESTVAR` is `Θ`, not `Var(y)`; `MANIFESTTRAITVAR` is not `MANIFESTVAR`; `Θ` does not enter lagged observed covariance; observed-indicator mean is `τ + λ μ`; `MANIFESTMEANS` is not `E(y)`; `CINT` is not `MANIFESTMEANS`; discrete latent mean is `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`; `T0MEANS` is not `μ_t`; evolved observed mean is `τ + λ μ_t`; `τ + λ μ_0` is not `E(y_t)`; contemporaneous `TDPREDEFFECT` impulse is `m x`, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that contemporaneous impulse is `τ + λ(μ_t + m x)`, and `τ + λ μ_t` is not that observed mean; time-independent `TIPREDEFFECT` increment is `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, not `M x`, not Voelkle Eq. 14, and not the coefficient `B`; Eq. 5 of that increment is `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; within-interval `TDPREDEFFECT` carry is `e^{A(t−u)} M x` for `t0 < u < t`, not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that carry is `τ + λ(μ_t + e^{a(t−u)} m x)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that carried observed mean when `u ≠ t`; §7.2 level-change `CINT` is `κ = −a m x` (`a < 0`; not the dissipating Dirac, not a free `CINT`, not `TIPREDEFFECT`; Eq. 3 of that setting is `(1 − e^{a Δt}) m x`); §7.2 extra-process contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, not the dissipating Dirac; `ε ≥ 0` fails closed; Eq. 5 of that contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`; extra `LAMBDA` is 0; `τ + λ μ_t` is not that observed mean; after-t0 extra-process `TDPREDEFFECT` uses `t − u` with `t0 < u < t` while `μ_t` uses `Δt`; that after-t0 observed mean is not the first-occasion extra-process observed mean; §7.2 `asymTIPREDEFFECT` is `-B z / a` for `a < 0` and is not `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`; §7.2 `addedTIPREDVAR` is `(B / a)² v` and is not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`; Table 2 `asymCINT` is `-κ / a` for `a < 0` and is not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`; p. 16 stationary `T0MEANS` is `-κ / a + −B z / a` and is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean; Eq. 5 of that constrained mean is `τ + λ(−κ / a + −B z / a)`; `τ + λ μ_0` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`; stationary `T0VAR` is `trait + −q / (2 a) + (B / a)² v` (not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Eq. 5 of that constrained variance is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`)); lagged stationary `T0VAR` is `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (trait and `addedTIPREDVAR` do not decay; contemporaneous `T0VAR` is not that lagged map; decaying the constrained total as if it were all state is not that lagged map; Eq. 5 of that lagged covariance is `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`; `Θ` does not enter; contemporaneous `Var(y_0)` is not that lagged observed covariance; the lagged latent covariance is not that observed covariance); later-occasion stationary `T0VAR` is `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`; evolving the constrained total as if it were all state is not that later map; the lagged covariance omits `Q_Δt`; `Q_Δt` is not that later map; Eq. 5 of that later-occasion variance is `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`; lagged observed covariance omits `Q_Δt` and `θ`; `MANIFESTVAR` is not `Var(y_t)`; the later-occasion latent variance is not `Var(y_t)`); predetermined later-occasion `T0VAR` is `trait + e^{2 a Δt} p_0 + Q_Δt + (B / a)² v` (free `T0VAR` `p_0` is not that later map; setting `p_0 = −q / (2 a)` recovers the stationary later-occasion map; stationary later variance uses `−q / (2 a)` in place of `p_0` and is not that later map when `p_0` is free; evolving `trait + p_0 + (B / a)² v` as if it were all state is not that later map; Eq. 5 of that predetermined later-occasion variance is `λ²(trait + e^{2 a Δt} p_0 + Q_Δt + (B / a)² v) + θ + ψ`; `MANIFESTVAR` is not `Var(y_t)`; the predetermined later-occasion latent variance is not `Var(y_t)`; stationary later observed variance is not that observed variance when `p_0` is free); predetermined lagged `T0VAR` is `trait + e^{a Δt} p_0 + (B / a)² v` (free `T0VAR` `p_0` is not that lagged map; setting `p_0 = −q / (2 a)` recovers the stationary lagged map; stationary lagged covariance uses `−q / (2 a)` in place of `p_0` and is not that lagged map when `p_0` is free; evolving `trait + p_0 + (B / a)² v` as if it were all state is not that lagged map; later-occasion variance includes `Q_Δt` and is not that lagged map; Eq. 5 of that predetermined lagged covariance is `λ²(trait + e^{a Δt} p_0 + (B / a)² v) + ψ`; `MANIFESTVAR` does not enter; the predetermined lagged latent covariance is not that observed covariance; predetermined later observed variance includes `Q_Δt` and `θ` and is not that lagged observed covariance; stationary lagged observed covariance is not that observed covariance when `p_0` is free; the predetermined first-occasion variance of §4.3 predetermined `T0VAR` is `trait + p_0 + (B / a)² v`; free `p_0` is not that map; stationary first-occasion variance uses `−q / (2 a)` in place of `p_0` and is not that map when `p_0` is free; lagged covariance decays the state and is not that map; later-occasion variance includes `Q_Δt` and is not that map; Eq. 5 of that predetermined first-occasion variance is `λ²(trait + p_0 + (B / a)² v) + θ + ψ`; `MANIFESTVAR` is not that first-occasion observed variance; the predetermined first-occasion latent variance is not that observed variance; stationary first-occasion observed variance is not that observed variance when `p_0` is free; predetermined later observed variance includes `Q_Δt` and is not that first-occasion observed variance; later-start lagged covariance of predetermined `T0VAR` is `trait + e^{a s}(e^{2 a u} p_0 + Q_u) + (B / a)² v` (Driver et al., 2017, §4.3 `startoffset`; Eq. 4; JSS PDF re-opened 2026-08-23T10:27Z; first-occasion lagged omits `e^{a s} Q_u`; later-occasion variance does not lag; stationary lagged uses `−q / (2 a)`; decaying the later total is not that map; Eq. 5 of that later-start lagged covariance is `λ²` of it plus `ψ`; `Θ` does not enter; first-occasion lagged observed omits `e^{a s} Q_u`; later observed variance includes `Q_u` and `θ`; later-start later-occasion variance of predetermined `T0VAR` is `trait + e^{2 a s}(e^{2 a u} p_0 + Q_u) + Q_s + (B / a)² v` (Driver et al., 2017, §4.3 `startoffset`; Eq. 3–4 Chapman–Kolmogorov `Q_{u+s} = e^{2 a s} Q_u + Q_s`; JSS PDF re-opened 2026-08-23T11:05Z; later-occasion variance at `u` omits `Q_s`; later-start lagged covariance omits `Q_s`; stationary later uses `−q / (2 a)`; evolving the later total as if it were all state is not that map; ignoring `startoffset` omits `e^{2 a s} Q_u`; Eq. 5 of that later-start later-occasion variance is `λ²` of it plus `θ + ψ`; `MANIFESTVAR` is not that observed variance; p. 16 `discreteDRIFTstd` is `e^{a Δt}` after strictly positive `asymDIFFUSION` `-q / (2 a)` (footnote 4; unstandardised `e^{a Δt}` is defined for growing `a ≥ 0` and for zero diffusion and is not `discreteDRIFTstd`; the §7.1 trait-plus-state autocorrelation uses `TRAITVAR` and is not `discreteDRIFTstd`; p. 16 `discreteDIFFUSIONstd` is `Q_Δt / (−q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` (footnote 4; unstandardised `Q_Δt` is defined for growing `a ≥ 0` and for zero diffusion and is not `discreteDIFFUSIONstd`; the continuous standardisation `−2 a` is not `discreteDIFFUSIONstd`; `Q_Δt / (trait + p + added)` uses `TRAITVAR` and is not `discreteDIFFUSIONstd`; `TRAITVAR` is not the standardisation variance; p. 16 `DIFFUSIONstd` is `q / (−q / (2 a)) = −2 a` after strictly positive `asymDIFFUSION` `-q / (2 a)` (Driver et al., 2017, p. 16; Eq. 4; footnote 4; JSS PDF re-opened 2026-08-23T13:20Z; unstandardised `q` is defined for growing `a ≥ 0` and for zero diffusion and is not `DIFFUSIONstd`; the discrete standardisation `Q_Δt / (−q / (2 a))` depends on `Δt` and is not `DIFFUSIONstd`; `q / (trait + p + added)` uses `TRAITVAR` and is not `DIFFUSIONstd`; `TRAITVAR` is not the standardisation variance; p. 16 `DRIFTstd` is the continuous auto-effect after strictly positive `asymDIFFUSION` `-q / (2 a)` (Driver et al., 2017, p. 16; Eq. 1; footnote 4; JSS PDF re-opened 2026-08-23T13:28Z); unstandardised `a` is defined for growing `a ≥ 0` and for zero diffusion and is not `DRIFTstd`; the discrete standardisation `e^{a Δt}` depends on the event interval and is not `DRIFTstd`; `a p / (trait + p + added)` uses `TRAITVAR` and is not `DRIFTstd`; `TRAITVAR` is not the standardisation variance); p. 16 `asymTIPREDEFFECTstd` is `(-B / a) · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` and strictly positive predictor variance `v` (Driver et al., 2017, p. 16; §7.2; footnote 4; JSS PDF re-opened 2026-08-23T14:25Z; unstandardised `-B / a` is defined for a zero coefficient and for zero predictor variance and is not `asymTIPREDEFFECTstd`; the finite-interval standardisation `A^{-1}[e^{A Δt} − I] B · √v / √p` depends on the event interval and is not `asymTIPREDEFFECTstd`; `(-B / a) · √v / √(trait + p + added)` uses `TRAITVAR` and is not `asymTIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); p. 16 `TIPREDEFFECTstd` is `B · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` and strictly positive predictor variance `v` (Driver et al., 2017, p. 16; §7.2; footnote 4; JSS PDF re-opened 2026-08-23T16:21Z; unstandardised `B` is defined for a zero coefficient and for zero predictor variance and is not `TIPREDEFFECTstd`; the asymptotic standardisation `(-B / a) · √v / √p` is the total change and is not `TIPREDEFFECTstd`; the finite-interval standardisation `A^{-1}[e^{A Δt} − I] B · √v / √p` depends on the event interval and is not `TIPREDEFFECTstd`; `B · √v / √(trait + p + added)` uses `TRAITVAR` and is not `TIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); Table 3 `T0TIPREDEFFECTstd` is `t0_b · √v / √p_0` after strictly positive free `T0VAR` `p_0` and strictly positive predictor variance `v` (Driver et al., 2017, Table 3, p. 13; p. 16; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T17:20Z; the affected variance is free `T0VAR`, not `asymDIFFUSION`; unstandardised `t0_b` is defined for a zero coefficient and for zero predictor variance and is not `T0TIPREDEFFECTstd`; `TIPREDEFFECTstd` `B · √v / √(-q / (2 a))` is the continuous coefficient and is not `T0TIPREDEFFECTstd`; `asymTIPREDEFFECTstd` `(-B / a) · √v / √p` is the total change and is not `T0TIPREDEFFECTstd`; `t0_b · √v / √(trait + p_0 + added)` uses `TRAITVAR` and is not `T0TIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); 2017-era `addedT0TIPREDVAR` is `t0_b² v` (Driver et al., 2017, Table 3, p. 13; p. 16; §7.2; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T18:20Z; `T0TIPREDEFFECT %*% TIPREDVAR %*% t(T0TIPREDEFFECT)` immediately after `T0TIPREDEFFECTstd`; form `t0_b` first, then square, then multiply by `v`; a zero coefficient or zero predictor variance is exactly zero; free `T0TIPREDEFFECT` does not require `a < 0`; `(B / a)² v` is `addedTIPREDVAR` and is not this first-occasion map; `t0_b · √v / √p_0` is `T0TIPREDEFFECTstd` and is not this variance; free `T0VAR` is not this extra TI variance; `TRAITVAR` is not this extra TI variance; Equation 5 of 2017-era `addedT0TIPREDVAR` is `λ² t0_b² v` (Driver et al., 2017, Eq. 5, p. 5; Table 3, p. 13; Table 2, p. 12; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T19:10Z; form `t0_b² v` first, then `(λ extra) λ` with `θ = 0`; a zero loading or zero extra is exactly zero; `t0_b² v` is the latent extra, not the observed extra; `λ² p_0 + θ` is first-occasion observed variance, not this extra; `λ² (B / a)² v` is Eq. 5 of `addedTIPREDVAR`, not this first-occasion observed extra; `MANIFESTVAR` `θ` is not this extra; Equation 5 of §7.2 `addedTIPREDVAR` is `λ² (B / a)² v`; form `(B / a)² v` first, then `(λ extra) λ` with `θ = 0`; a zero loading or zero extra is exactly zero; lasting asymptotic extra requires `a < 0`; `(B / a)² v` is the latent extra, not the observed extra; `λ² t0_b² v` is first-occasion extra observed TI variance, not this extra; `λ² p + θ` is stationary observed variance, not this extra; `MANIFESTVAR` `θ` is not this extra; p. 16 `TDPREDEFFECTstd` is `m · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` and strictly positive time-dependent predictor variance; unstandardised `M` is not `TDPREDEFFECTstd`; `TIPREDEFFECTstd` is not `TDPREDEFFECTstd` even when `M = B`; intercept-style `A^{-1}[e^{A Δt} − I] M · √v / √p` is not `TDPREDEFFECTstd`; `m · √v / √(trait + p + added)` uses `TRAITVAR` and is not `TDPREDEFFECTstd`; Table 3 / p. 16 `T0TDPREDEFFECTstd` is `t0_m · √v / √p_0` after strictly positive free `T0VAR` and strictly positive TD predictor variance; unstandardised `t0_m` is not `T0TDPREDEFFECTstd`; `TDPREDEFFECTstd` uses `asymDIFFUSION` and is not `T0TDPREDEFFECTstd`; `T0TIPREDEFFECTstd` is not `T0TDPREDEFFECTstd` even when `t0_m = t0_b`; `t0_m · √v / √(trait + p_0 + added)` uses `TRAITVAR` and is not `T0TDPREDEFFECTstd`; free `T0VAR` does not require `a < 0`; p. 16 `T0VARstd` is `p_0 / p_0 = 1` after strictly positive free `T0VAR` (`solve(sqrt(diag(T0VAR))) %&% T0VAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; default ridge is 0); unstandardised `T0VAR` is not `T0VARstd`; `T0TDPREDEFFECTstd` is not `T0VARstd`; `addedT0TIPREDVAR` is not `T0VARstd`; p. 16 `TRAITVARstd` is `trait / trait = 1` after strictly positive `TRAITVAR` (`solve(sqrt(diag(TRAITVAR))) %&% TRAITVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; no ridge addend); unstandardised `TRAITVAR` is not `TRAITVARstd`; `T0VARstd` is not `TRAITVARstd` even when both equal 1; `addedT0TIPREDVAR` is not `TRAITVARstd`; p. 16 `MANIFESTTRAITVARstd` is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR` (`solve(sqrt(diag(MANIFESTTRAITVAR))) %&% MANIFESTTRAITVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0); unstandardised `MANIFESTTRAITVAR` is not `MANIFESTTRAITVARstd`; `TRAITVARstd` is not `MANIFESTTRAITVARstd` even when both equal 1; `MANIFESTVAR` is not `MANIFESTTRAITVARstd`; p. 16 `MANIFESTVARstd` is `θ / θ = 1` after strictly positive `MANIFESTVAR` (`solve(sqrt(diag(MANIFESTVAR))) %&% MANIFESTVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; 2017-era `dimnames` assignment to `latentNames` is a source bug); unstandardised `MANIFESTVAR` is not `MANIFESTVARstd`; `MANIFESTTRAITVARstd` is not `MANIFESTVARstd` even when both equal 1; Equation 5 `Var(y)` is not `MANIFESTVARstd`; p. 16 `TIPREDVARstd` is `v / v = 1` after strictly positive `TIPREDVAR` (`solve(sqrt(diag(TIPREDVAR))) %&% TIPREDVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; `dimnames` are `TIpredNames`); unstandardised `TIPREDVAR` is not `TIPREDVARstd`; `MANIFESTVARstd` is not `TIPREDVARstd` even when both equal 1; §7.2 `addedTIPREDVAR` is not `TIPREDVARstd`; p. 16 `asymDIFFUSIONstd` is `p / p = 1` after strictly positive `asymDIFFUSION` (`solve(sqrt(diag(asymDIFFUSION))) %&% asymDIFFUSION`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; `dimnames` are `latentNames`); unstandardised `asymDIFFUSION` is not `asymDIFFUSIONstd`; `TIPREDVARstd` is not `asymDIFFUSIONstd` even when both equal 1; `DIFFUSIONstd` `−2 a` is not `asymDIFFUSIONstd`; p. 16 `discreteCINTstd` is `A^{-1}[e^{A Δt} − I] κ / √p` after strictly positive `asymDIFFUSION`; unstandardised `discreteCINT` is not `discreteCINTstd`; `κ / √p` is not `discreteCINTstd`; `(-κ / a) / √p` is not `discreteCINTstd`; `asymCINTstd` is `(-κ / a) / √p` after strictly positive `asymDIFFUSION`; unstandardised `asymCINT` is not `asymCINTstd`; `κ / √p` is not `asymCINTstd`; `discreteCINTstd` is not `asymCINTstd`; `T0MEANSstd` is `μ_0 / √p_0` after strictly positive free `T0VAR`; unstandardised `T0MEANS` is not `T0MEANSstd`; `T0VARstd` is not `T0MEANSstd`; `μ_0 / √asymDIFFUSION` is not `T0MEANSstd`; `MANIFESTMEANSstd` is `τ / √θ` after strictly positive `MANIFESTVAR`; unstandardised `MANIFESTMEANS` is not `MANIFESTMEANSstd`; `MANIFESTVARstd` is not `MANIFESTMEANSstd`; `τ / √(λ² Var(η) + θ)` is not `MANIFESTMEANSstd`; p. 16 `CINTstd` is `κ / √p` after strictly positive `asymDIFFUSION`; unstandardised `CINT` is not `CINTstd`; `asymCINTstd` is not `CINTstd`; `discreteCINTstd` is not `CINTstd`; `κ / √(trait + p + added)` is not `CINTstd`;))))), irregular already-centered residual lag, and strong/strict-gated latent means on the stacked psychometric PR (two-observation residual variance is identically `0` and caps at strong/scalar; Putnick & Bornstein, 2016, PMC5145197 opened 2026-08-19T22:15Z); full ESEM/DSEM remaining | partial | diff --git a/docs/adr/0062-lineage-criterion-analysis-run.md b/docs/adr/0063-lineage-criterion-analysis-run.md similarity index 98% rename from docs/adr/0062-lineage-criterion-analysis-run.md rename to docs/adr/0063-lineage-criterion-analysis-run.md index 755437669..0e3964956 100644 --- a/docs/adr/0062-lineage-criterion-analysis-run.md +++ b/docs/adr/0063-lineage-criterion-analysis-run.md @@ -1,4 +1,4 @@ -# ADR 0062 — Independent TDT link-criterion fitting as an analysis-run output profile +# ADR 0063 — Independent TDT link-criterion fitting as an analysis-run output profile **Decision status:** Accepted **Implementation maturity:** active-PR — composed on this branch; not implemented-main diff --git a/docs/adr/README.md b/docs/adr/README.md index b09d2bfd1..657cd92bd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,7 +28,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0020](0020-span-grounded-semantic-units.md) | Span-grounded semantic units; language tags are not identity | Accepted | active-PR | First ADR 0004 production slice; concept alignment, invariance, and topic estimation are not claimed. | | [0021](0021-lineageweave-project-history-boundary.md) | LineageWeave project-history service boundary | Accepted | active-PR | Credential-free bounded project-history API preserves LineageWeave authorization ownership. | | [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. | -| [0062](0062-lineage-criterion-analysis-run.md) | Independent TDT link-criterion as an analysis-run profile | Accepted | active-PR | Complements ADR 0022/0023; independent criterion fitting, not date inference and not a Bayesian sampler. | +| [0063](0063-lineage-criterion-analysis-run.md) | Independent TDT link-criterion as an analysis-run profile | Accepted | active-PR | Complements ADR 0022/0023; independent criterion fitting, not date inference and not a Bayesian sampler. | | [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. | | [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. | @@ -139,7 +139,7 @@ Use the narrowest owning ADR when decisions overlap: - **project-history wire-size symmetry:** ADR 0019. - **LineageWeave project-history service boundary:** ADR 0021. - **accepted-run execution and terminal artifact production:** ADR 0022. -- **independent TDT link-criterion analysis-run claim boundary:** ADR 0062. +- **independent TDT link-criterion analysis-run claim boundary:** ADR 0063. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/lineage-criterion-analysis-run.md b/docs/doctoring/lineage-criterion-analysis-run.md index 6a701cee2..0ffba33c5 100644 --- a/docs/doctoring/lineage-criterion-analysis-run.md +++ b/docs/doctoring/lineage-criterion-analysis-run.md @@ -1,6 +1,6 @@ # Independent TDT link-criterion analysis-run composition -**Active slice:** ADR 0062 / `lineage_criterion_v1` +**Active slice:** ADR 0063 / `lineage_criterion_v1` **Protected-main status:** not implemented-main `analysis_engine` already fits independently observed TDT link-criterion From f475a312dde77aa3c316b5249f3eb2b0b036835b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:02:40 +0900 Subject: [PATCH 03/13] test(analysis): expose lineage criterion temporal contract gaps --- .../lineage_criterion_execution_contract.rs | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs b/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs index 8bfbd6e6a..e905a2215 100644 --- a/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs +++ b/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs @@ -50,20 +50,26 @@ fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { .expect("accepted") } -fn execute( +fn execute_with_observations( request: &AnalysisRunRequest, + observations: &[LineageCriterionObservation], ) -> Result { - let observations = observations(); execute_lineage_criterion_run( request, &accepted(request), "snapshot-lineage-criterion", cutoff(), - &LineageCriterionInput::new(&observations, 32), + &LineageCriterionInput::new(observations, 32), "2026-08-02T00:00:00Z", ) } +fn execute( + request: &AnalysisRunRequest, +) -> Result { + execute_with_observations(request, &observations()) +} + #[test] fn identified_pairs_emit_digest_bound_counts_without_inferring_dates() { let request = request(); @@ -92,6 +98,35 @@ fn identified_pairs_emit_digest_bound_counts_without_inferring_dates() { ); } +#[test] +fn equivalent_cutoff_spellings_bind_the_same_instant() { + let canonical_request = request(); + let baseline = execute(&canonical_request).expect("canonical cutoff"); + let mut offset_request = canonical_request; + offset_request.knowledge_cutoff = "2026-08-01T01:00:00+01:00".into(); + let equivalent = execute(&offset_request).expect("equivalent instant"); + assert_eq!(equivalent.artifact, baseline.artifact); + assert_eq!(equivalent.terminal_result.summary, baseline.terminal_result.summary); +} + +#[test] +fn malformed_event_time_draws_fail_closed() { + let request = request(); + let mut invalid_predecessor = observations(); + invalid_predecessor[0].predecessor_event_time_draws[0] = "not-an-event-time".into(); + assert_eq!( + execute_with_observations(&request, &invalid_predecessor), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut invalid_successor = observations(); + invalid_successor[0].successor_event_time_draws[0] = "also-not-an-event-time".into(); + assert_eq!( + execute_with_observations(&request, &invalid_successor), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + #[test] fn invalid_observations_and_criterion_refusal_fail_closed() { let request = request(); From a33ee6807ab843b11d085a28b365fd08b7948810 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:03:11 +0900 Subject: [PATCH 04/13] fix(analysis): bind lineage criterion cutoff by instant and validate event times --- .../src/lineage_criterion_artifact.rs | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/analysis_engine/src/lineage_criterion_artifact.rs b/crates/analysis_engine/src/lineage_criterion_artifact.rs index e891cac2a..6043ac484 100644 --- a/crates/analysis_engine/src/lineage_criterion_artifact.rs +++ b/crates/analysis_engine/src/lineage_criterion_artifact.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use temporal_core::KnowledgeCutoff; +use temporal_core::{EventTime, KnowledgeCutoff}; use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, }; @@ -166,13 +166,24 @@ pub fn execute_lineage_criterion_run( if request.snapshot_id != snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } - if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + let request_cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if request_cutoff.instant() != knowledge_cutoff.instant() || request.model_contract_version != LINEAGE_CRITERION_MODEL_CONTRACT_VERSION || request.output_profile != LINEAGE_CRITERION_OUTPUT_PROFILE || input.draw_count() == 0 { return Err(AnalysisEngineError::InvalidEvidence); } + if input.observations().iter().any(|observation| { + observation + .predecessor_event_time_draws + .iter() + .chain(&observation.successor_event_time_draws) + .any(|value| EventTime::parse_rfc3339(value).is_err()) + }) { + return Err(AnalysisEngineError::InvalidEvidence); + } let fits = fit_lineage_criterion_posteriors(input.observations(), input.draw_count()).map_err( |error| match error { @@ -200,12 +211,7 @@ pub fn execute_lineage_criterion_run( inference_status: LINEAGE_CRITERION_INFERENCE_STATUS.into(), }; let digest = artifact.sha256()?; - let summary = AnalysisResultSummary::new( - "lineage_criterion", - pair_count, - 2, - LINEAGE_CRITERION_INFERENCE_STATUS, - )?; + let summary = AnalysisResultSummary::new("lineage_criterion", pair_count, 2, "validated")?; let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, From 8f4523cf33f75c99a7f3d827745f5580d5139b69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:05:16 +0900 Subject: [PATCH 05/13] test(analysis): expose lineage criterion provenance and resource gaps --- .../lineage_criterion_execution_contract.rs | 163 +++++++++++++++--- 1 file changed, 137 insertions(+), 26 deletions(-) diff --git a/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs b/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs index e905a2215..c23dbde4b 100644 --- a/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs +++ b/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs @@ -5,13 +5,19 @@ use analysis_engine::{ LINEAGE_CRITERION_MODEL_CONTRACT_VERSION, LINEAGE_CRITERION_OUTPUT_PROFILE, LineageCriterionInput, LineageCriterionObservation, execute_lineage_criterion_run, }; -use temporal_core::KnowledgeCutoff; +use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; +const SNAPSHOT_ID: &str = "snapshot-lineage-criterion"; + fn cutoff() -> KnowledgeCutoff { KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") } +fn available_time(value: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(value).expect("available time") +} + fn observation(pair_id: &str, successes: u32, trials: u32) -> LineageCriterionObservation { LineageCriterionObservation { pair_id: pair_id.into(), @@ -29,12 +35,20 @@ fn observations() -> Vec { ] } +fn visible_snapshot_ids(len: usize) -> Vec { + vec![SNAPSHOT_ID.to_owned(); len] +} + +fn visible_available_times(len: usize) -> Vec { + vec![available_time("2026-07-01T00:00:00Z"); len] +} + fn request() -> AnalysisRunRequest { AnalysisRunRequest { contract_version: 1, idempotency_key: "lineage-criterion-idem".into(), tenant_workspace_id: "tenant-workspace".into(), - snapshot_id: "snapshot-lineage-criterion".into(), + snapshot_id: SNAPSHOT_ID.into(), knowledge_cutoff: "2026-08-01T00:00:00Z".into(), model_contract_version: LINEAGE_CRITERION_MODEL_CONTRACT_VERSION.into(), output_profile: LINEAGE_CRITERION_OUTPUT_PROFILE.into(), @@ -50,20 +64,38 @@ fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { .expect("accepted") } -fn execute_with_observations( +fn execute_with_provenance( request: &AnalysisRunRequest, observations: &[LineageCriterionObservation], + snapshot_ids: &[String], + available_times: &[AvailableTime], + draw_count: usize, ) -> Result { execute_lineage_criterion_run( request, &accepted(request), - "snapshot-lineage-criterion", + SNAPSHOT_ID, cutoff(), - &LineageCriterionInput::new(observations, 32), + &LineageCriterionInput::new(observations, snapshot_ids, available_times, draw_count), "2026-08-02T00:00:00Z", ) } +fn execute_with_observations( + request: &AnalysisRunRequest, + observations: &[LineageCriterionObservation], +) -> Result { + let snapshot_ids = visible_snapshot_ids(observations.len()); + let available_times = visible_available_times(observations.len()); + execute_with_provenance( + request, + observations, + &snapshot_ids, + &available_times, + 32, + ) +} + fn execute( request: &AnalysisRunRequest, ) -> Result { @@ -88,6 +120,7 @@ fn identified_pairs_emit_digest_bound_counts_without_inferring_dates() { execution.terminal_result.run_state, AnalysisRunTerminalState::Succeeded ); + assert_eq!(execution.terminal_result.summary.validation_status, "validated"); assert_eq!( execution.terminal_result.result_sha256.as_deref(), Some(execution.artifact.sha256().expect("digest").as_str()) @@ -110,7 +143,65 @@ fn equivalent_cutoff_spellings_bind_the_same_instant() { } #[test] -fn malformed_event_time_draws_fail_closed() { +fn future_duplicate_and_malformed_evidence_cannot_change_historical_replay() { + let request = request(); + let baseline_observations = observations(); + let baseline = execute_with_observations(&request, &baseline_observations).expect("baseline"); + + let mut replay_observations = baseline_observations; + let mut future_duplicate = observation("pair-a", 10_000, 10_000); + future_duplicate.predecessor_event_time_draws[0] = "future-malformed-time".into(); + replay_observations.push(future_duplicate); + let replay_snapshot_ids = visible_snapshot_ids(replay_observations.len()); + let mut replay_available_times = visible_available_times(replay_observations.len()); + *replay_available_times.last_mut().expect("future availability") = + available_time("2026-08-02T00:00:00Z"); + + let replay = execute_with_provenance( + &request, + &replay_observations, + &replay_snapshot_ids, + &replay_available_times, + 32, + ) + .expect("future evidence must be censored before validation"); + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result.summary, baseline.terminal_result.summary); +} + +#[test] +fn cross_snapshot_evidence_and_misaligned_provenance_fail_closed() { + let request = request(); + let observations = observations(); + let available_times = visible_available_times(observations.len()); + let mut wrong_snapshot_ids = visible_snapshot_ids(observations.len()); + wrong_snapshot_ids[1] = "snapshot-other".into(); + assert_eq!( + execute_with_provenance( + &request, + &observations, + &wrong_snapshot_ids, + &available_times, + 32, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let short_snapshot_ids = vec![SNAPSHOT_ID.to_owned()]; + assert_eq!( + execute_with_provenance( + &request, + &observations, + &short_snapshot_ids, + &available_times, + 32, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn malformed_visible_event_time_draws_fail_closed() { let request = request(); let mut invalid_predecessor = observations(); invalid_predecessor[0].predecessor_event_time_draws[0] = "not-an-event-time".into(); @@ -128,40 +219,53 @@ fn malformed_event_time_draws_fail_closed() { } #[test] -fn invalid_observations_and_criterion_refusal_fail_closed() { +fn oversized_draw_budget_fails_before_posterior_materialization() { let request = request(); + let observations = vec![observation("pair-a", 1, 2)]; + let snapshot_ids = visible_snapshot_ids(1); + let available_times = visible_available_times(1); assert_eq!( - execute_lineage_criterion_run( + execute_with_provenance( &request, - &accepted(&request), - "snapshot-lineage-criterion", - cutoff(), - &LineageCriterionInput::new(&[], 32), - "2026-08-02T00:00:00Z", + &observations, + &snapshot_ids, + &available_times, + 1_000_001, ), + Err(AnalysisEngineError::LimitExceeded) + ); +} + +#[test] +fn invalid_observations_and_criterion_refusal_fail_closed() { + let request = request(); + assert_eq!( + execute_with_provenance(&request, &[], &[], &[], 32), Err(AnalysisEngineError::InvalidEvidence) ); let observations = observations(); + let snapshot_ids = visible_snapshot_ids(observations.len()); + let available_times = visible_available_times(observations.len()); assert_eq!( - execute_lineage_criterion_run( + execute_with_provenance( &request, - &accepted(&request), - "snapshot-lineage-criterion", - cutoff(), - &LineageCriterionInput::new(&observations, 0), - "2026-08-02T00:00:00Z", + &observations, + &snapshot_ids, + &available_times, + 0, ), Err(AnalysisEngineError::InvalidEvidence) ); let invalid = vec![observation("pair-a", 3, 2)]; + let snapshot_ids = visible_snapshot_ids(1); + let available_times = visible_available_times(1); assert_eq!( - execute_lineage_criterion_run( + execute_with_provenance( &request, - &accepted(&request), - "snapshot-lineage-criterion", - cutoff(), - &LineageCriterionInput::new(&invalid, 32), - "2026-08-02T00:00:00Z", + &invalid, + &snapshot_ids, + &available_times, + 32, ), Err(AnalysisEngineError::LineageCriterionFitFailure) ); @@ -171,13 +275,20 @@ fn invalid_observations_and_criterion_refusal_fail_closed() { fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { let request = request(); let observations = observations(); + let snapshot_ids = visible_snapshot_ids(observations.len()); + let available_times = visible_available_times(observations.len()); assert_eq!( execute_lineage_criterion_run( &request, &accepted(&request), "other-snapshot", cutoff(), - &LineageCriterionInput::new(&observations, 32), + &LineageCriterionInput::new( + &observations, + &snapshot_ids, + &available_times, + 32, + ), "2026-08-02T00:00:00Z", ), Err(AnalysisEngineError::SnapshotMismatch) From e6de3aad0ded19eeefeb67a7f9dc1795025d3b1b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:06:16 +0900 Subject: [PATCH 06/13] fix(analysis): admit lineage criterion evidence with explicit provenance --- .../src/lineage_criterion_artifact.rs | 182 +++++++++++++++--- 1 file changed, 151 insertions(+), 31 deletions(-) diff --git a/crates/analysis_engine/src/lineage_criterion_artifact.rs b/crates/analysis_engine/src/lineage_criterion_artifact.rs index 6043ac484..660ac08e3 100644 --- a/crates/analysis_engine/src/lineage_criterion_artifact.rs +++ b/crates/analysis_engine/src/lineage_criterion_artifact.rs @@ -1,14 +1,15 @@ //! Digest-bound independent TDT link-criterion fitting as an analysis-run profile. +use corpus_split::cutoff_eligible; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use temporal_core::{EventTime, KnowledgeCutoff}; +use temporal_core::{AvailableTime, EventTime, KnowledgeCutoff}; use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, }; use crate::{ - AnalysisEngineError, LineageCriterionFitError, LineageCriterionObservation, + AnalysisEngineError, LineageCriterionFitError, LineageCriterionObservation, MAX_EVIDENCE_UNITS, fit_lineage_criterion_posteriors, format_digest, require_receipt_identity, valid_identifier, }; @@ -18,24 +19,38 @@ pub const LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION: &str = "tepp.lineage_criter pub const LINEAGE_CRITERION_MODEL_CONTRACT_VERSION: &str = "lineage_criterion_v1"; /// Analysis-run output profile required for a lineage-criterion artifact. pub const LINEAGE_CRITERION_OUTPUT_PROFILE: &str = "lineage_criterion_v1"; -/// Maximum canonical artifact JSON size. +/// Maximum accepted lineage-criterion artifact JSON size. pub const LINEAGE_CRITERION_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const MAX_LINEAGE_CRITERION_DRAW_VALUES: usize = 1_000_000; const LINEAGE_CRITERION_INFERENCE_STATUS: &str = "independent_tdt_criterion_not_date_from_record_order"; /// Cutoff-safe independent TDT link-criterion payload bound to the existing fitter. +/// +/// Provenance is carried in parallel slices because the scientific +/// [`LineageCriterionObservation`] remains the estimator-owned counts/draws +/// contract. Alignment is checked before any observation is admitted. #[derive(Clone, Debug)] pub struct LineageCriterionInput<'a> { observations: &'a [LineageCriterionObservation], + snapshot_ids: &'a [String], + available_times: &'a [AvailableTime], draw_count: usize, } impl<'a> LineageCriterionInput<'a> { - /// Construct a lineage-criterion payload from existing fitter inputs. + /// Construct a lineage-criterion payload with explicit per-pair provenance. #[must_use] - pub const fn new(observations: &'a [LineageCriterionObservation], draw_count: usize) -> Self { + pub const fn new( + observations: &'a [LineageCriterionObservation], + snapshot_ids: &'a [String], + available_times: &'a [AvailableTime], + draw_count: usize, + ) -> Self { Self { observations, + snapshot_ids, + available_times, draw_count, } } @@ -46,6 +61,18 @@ impl<'a> LineageCriterionInput<'a> { self.observations } + /// Borrow immutable source snapshot identities aligned to observations. + #[must_use] + pub const fn snapshot_ids(&self) -> &'a [String] { + self.snapshot_ids + } + + /// Borrow evidence availability times aligned to observations. + #[must_use] + pub const fn available_times(&self) -> &'a [AvailableTime] { + self.available_times + } + /// Return the common temporal-draw count carried without alteration. #[must_use] pub const fn draw_count(&self) -> usize { @@ -65,7 +92,7 @@ pub struct LineageCriterionArtifact { pub snapshot_id: String, /// Historical evidence cutoff used by the run. pub knowledge_cutoff: String, - /// Number of independently observed exact pairs. + /// Number of independently observed exact pairs admitted at the cutoff. pub pair_count: u64, /// Common temporal-draw count carried without alteration. pub draw_count: u64, @@ -79,7 +106,7 @@ impl LineageCriterionArtifact { /// # Errors /// /// Returns [`AnalysisEngineError::InvalidLineageCriterionArtifact`] when - /// the schema, identifiers, counts, or claim boundary fail. + /// the schema, identifiers, counts, resource budget, or claim boundary fail. pub fn from_json(payload: &str) -> Result { if payload.len() > LINEAGE_CRITERION_ARTIFACT_BYTE_LIMIT { return Err(AnalysisEngineError::LimitExceeded); @@ -92,17 +119,16 @@ impl LineageCriterionArtifact { /// Serialize canonical validated artifact JSON. /// + /// Valid identifiers, strict timestamp syntax, and bounded pair/draw counts + /// make canonical output smaller than [`LINEAGE_CRITERION_ARTIFACT_BYTE_LIMIT`]. + /// Untrusted input remains capped by [`Self::from_json`]. + /// /// # Errors /// - /// Returns a typed validation, serialization, or size failure. + /// Returns a typed validation or serialization failure. pub fn to_json(&self) -> Result { self.validate()?; - let payload = - serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; - if payload.len() > LINEAGE_CRITERION_ARTIFACT_BYTE_LIMIT { - return Err(AnalysisEngineError::LimitExceeded); - } - Ok(payload) + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) } /// Return the lowercase SHA-256 digest of canonical artifact JSON. @@ -116,12 +142,20 @@ impl LineageCriterionArtifact { } fn validate(&self) -> Result<(), AnalysisEngineError> { + let pair_count = usize::try_from(self.pair_count).ok(); + let draw_count = usize::try_from(self.draw_count).ok(); if self.schema_version != LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION || !valid_identifier(&self.run_id) || !valid_identifier(&self.snapshot_id) || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() - || self.pair_count == 0 - || self.draw_count == 0 + || pair_count.is_none() + || draw_count.is_none() + || validate_resource_budget( + pair_count.unwrap_or_default(), + draw_count.unwrap_or_default(), + ) + .is_err() + || pair_count == Some(0) || self.inference_status != LINEAGE_CRITERION_INFERENCE_STATUS { return Err(AnalysisEngineError::InvalidLineageCriterionArtifact); @@ -133,7 +167,7 @@ impl LineageCriterionArtifact { /// One completed lineage-criterion artifact and its terminal result. #[derive(Clone, Debug, PartialEq)] pub struct LineageCriterionExecution { - /// Digest-bound completed lineage-criterion artifact. + /// Digest-bound completed lineage-criterion census. pub artifact: LineageCriterionArtifact, /// Terminal result carrying the artifact identity, digest, and schema. pub terminal_result: AnalysisRunTerminalResult, @@ -145,13 +179,15 @@ pub struct LineageCriterionExecution { /// reimplement Jeffreys fitting, infer a date from record order, or promote /// CHRONOS predictions to observed facts. Raw posteriors stay with the /// scientific fitter; the operator artifact carries only bounded pair and -/// draw counts. Event-time draws remain producer evidence. This is not a -/// Bayesian sampler, not GPU execution, and not topic birth/split/merge. +/// draw counts. Event-time draws remain producer evidence and are distinct +/// from the evidence-availability clock used for historical admission. This is +/// not a Bayesian sampler, GPU execution, or topic birth/split/merge. /// /// # Errors /// -/// Returns a request/receipt/snapshot/cutoff/profile error, invalid -/// observations, fitter refusal, or invalid artifact error. +/// Returns a request/receipt/snapshot/cutoff/profile error, malformed or +/// unavailable provenance, resource limit, fitter refusal, or invalid artifact +/// error. pub fn execute_lineage_criterion_run( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, @@ -171,21 +207,42 @@ pub fn execute_lineage_criterion_run( if request_cutoff.instant() != knowledge_cutoff.instant() || request.model_contract_version != LINEAGE_CRITERION_MODEL_CONTRACT_VERSION || request.output_profile != LINEAGE_CRITERION_OUTPUT_PROFILE - || input.draw_count() == 0 + || input.observations().len() != input.snapshot_ids().len() + || input.observations().len() != input.available_times().len() + || input.draw_count() < 2 { return Err(AnalysisEngineError::InvalidEvidence); } - if input.observations().iter().any(|observation| { - observation + if input.observations().len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + + let mut admitted_observations = Vec::with_capacity(input.observations().len()); + for ((observation, observation_snapshot_id), available_time) in input + .observations() + .iter() + .zip(input.snapshot_ids()) + .zip(input.available_times()) + { + if observation_snapshot_id != snapshot_id { + return Err(AnalysisEngineError::InvalidEvidence); + } + if !cutoff_eligible(available_time, &knowledge_cutoff) { + continue; + } + if observation .predecessor_event_time_draws .iter() .chain(&observation.successor_event_time_draws) .any(|value| EventTime::parse_rfc3339(value).is_err()) - }) { - return Err(AnalysisEngineError::InvalidEvidence); + { + return Err(AnalysisEngineError::InvalidEvidence); + } + admitted_observations.push(observation.clone()); } + validate_resource_budget(admitted_observations.len(), input.draw_count())?; - let fits = fit_lineage_criterion_posteriors(input.observations(), input.draw_count()).map_err( + let fits = fit_lineage_criterion_posteriors(&admitted_observations, input.draw_count()).map_err( |error| match error { LineageCriterionFitError::EmptyInput | LineageCriterionFitError::InvalidPairIdentity @@ -227,13 +284,34 @@ pub fn execute_lineage_criterion_run( }) } +fn validate_resource_budget( + pair_count: usize, + draw_count: usize, +) -> Result<(), AnalysisEngineError> { + if pair_count > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + if draw_count < 2 { + return Err(AnalysisEngineError::InvalidEvidence); + } + let draw_values = pair_count + .checked_mul(draw_count) + .ok_or(AnalysisEngineError::LimitExceeded)?; + if draw_values > MAX_LINEAGE_CRITERION_DRAW_VALUES { + return Err(AnalysisEngineError::LimitExceeded); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{ LINEAGE_CRITERION_ARTIFACT_BYTE_LIMIT, LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION, LINEAGE_CRITERION_INFERENCE_STATUS, LineageCriterionArtifact, LineageCriterionInput, + MAX_LINEAGE_CRITERION_DRAW_VALUES, validate_resource_budget, }; - use crate::{AnalysisEngineError, LineageCriterionObservation}; + use crate::{AnalysisEngineError, LineageCriterionObservation, MAX_EVIDENCE_UNITS}; + use temporal_core::AvailableTime; fn artifact() -> LineageCriterionArtifact { LineageCriterionArtifact { @@ -255,7 +333,7 @@ mod tests { } #[test] - fn artifact_round_trip_and_size_bounds_fail_closed() { + fn artifact_round_trip_and_input_size_bounds_fail_closed() { let artifact = artifact(); let payload = artifact.to_json().expect("json"); assert_eq!( @@ -275,6 +353,22 @@ mod tests { ); } + #[test] + fn maximal_valid_artifact_stays_below_the_input_wire_cap() { + let artifact = LineageCriterionArtifact { + schema_version: LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "\\".repeat(256), + snapshot_id: "\\".repeat(256), + knowledge_cutoff: "2026-08-01T23:59:59.999999999+23:59".into(), + pair_count: u64::try_from(MAX_EVIDENCE_UNITS).expect("pair bound"), + draw_count: 10, + inference_status: LINEAGE_CRITERION_INFERENCE_STATUS.into(), + }; + let payload = artifact.to_json().expect("bounded output"); + assert!(payload.len() < LINEAGE_CRITERION_ARTIFACT_BYTE_LIMIT); + assert_eq!(LineageCriterionArtifact::from_json(&payload), Ok(artifact)); + } + #[test] fn artifact_metadata_tampering_fails_closed() { let artifact = artifact(); @@ -321,7 +415,27 @@ mod tests { } #[test] - fn input_accessors_expose_observations_and_draw_count() { + fn resource_budget_bounds_pairs_and_materialized_draw_values() { + assert_eq!( + validate_resource_budget(MAX_EVIDENCE_UNITS, 10), + Ok(()) + ); + assert_eq!( + validate_resource_budget(MAX_EVIDENCE_UNITS + 1, 2), + Err(AnalysisEngineError::LimitExceeded) + ); + assert_eq!( + validate_resource_budget(1, MAX_LINEAGE_CRITERION_DRAW_VALUES + 1), + Err(AnalysisEngineError::LimitExceeded) + ); + assert_eq!( + validate_resource_budget(1, 1), + Err(AnalysisEngineError::InvalidEvidence) + ); + } + + #[test] + fn input_accessors_expose_observations_provenance_and_draw_count() { let observations = [LineageCriterionObservation { pair_id: "pair-a".into(), successes: 1, @@ -329,8 +443,14 @@ mod tests { predecessor_event_time_draws: vec!["2026-01-01T00:00:00Z".into(); 32], successor_event_time_draws: vec!["2026-01-02T00:00:00Z".into(); 32], }]; - let input = LineageCriterionInput::new(&observations, 32); + let snapshot_ids = ["snapshot-1".to_owned()]; + let available_times = [ + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available time"), + ]; + let input = LineageCriterionInput::new(&observations, &snapshot_ids, &available_times, 32); assert_eq!(input.observations(), &observations); + assert_eq!(input.snapshot_ids(), &snapshot_ids); + assert_eq!(input.available_times(), &available_times); assert_eq!(input.draw_count(), 32); } } From 55edf20de86ac42540c294ad333d20d8cc4d045e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:06:33 +0900 Subject: [PATCH 07/13] fix(analysis): promote cutoff admission helper to runtime dependency --- crates/analysis_engine/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 7322212b2..89aeaf5f2 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -14,6 +14,7 @@ categories.workspace = true publish = false [dependencies] +corpus_split = { path = "../corpus_split", version = "0.2.0" } event_core = { path = "../event_core", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } @@ -24,7 +25,6 @@ topic_measurement = { path = "../topic_measurement", version = "0.2.0" } uuid.workspace = true [dev-dependencies] -corpus_split = { path = "../corpus_split", version = "0.2.0" } membership_core = { path = "../membership_core", version = "0.2.0" } relation_graph = { path = "../relation_graph", version = "0.2.0" } From 1591d7a998659969161b5819eae1cb90844d049c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:07:18 +0900 Subject: [PATCH 08/13] fix(test): read optional lineage criterion terminal summary safely --- .../tests/lineage_criterion_execution_contract.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs b/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs index c23dbde4b..36d84bf34 100644 --- a/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs +++ b/crates/analysis_engine/tests/lineage_criterion_execution_contract.rs @@ -120,7 +120,15 @@ fn identified_pairs_emit_digest_bound_counts_without_inferring_dates() { execution.terminal_result.run_state, AnalysisRunTerminalState::Succeeded ); - assert_eq!(execution.terminal_result.summary.validation_status, "validated"); + assert_eq!( + execution + .terminal_result + .summary + .as_ref() + .expect("summary") + .validation_status, + "validated" + ); assert_eq!( execution.terminal_result.result_sha256.as_deref(), Some(execution.artifact.sha256().expect("digest").as_str()) From dbdeb5ecb7389e5c7dbccfd617fde190bdb095bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:09:50 +0900 Subject: [PATCH 09/13] docs(adr): keep lineage criterion composition proposed and cutoff-explicit --- .../0063-lineage-criterion-analysis-run.md | 119 ++++++++---------- 1 file changed, 52 insertions(+), 67 deletions(-) diff --git a/docs/adr/0063-lineage-criterion-analysis-run.md b/docs/adr/0063-lineage-criterion-analysis-run.md index 0e3964956..de3fd0f66 100644 --- a/docs/adr/0063-lineage-criterion-analysis-run.md +++ b/docs/adr/0063-lineage-criterion-analysis-run.md @@ -1,81 +1,63 @@ # ADR 0063 — Independent TDT link-criterion fitting 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 0023 (lineage-criterion anchor) 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. +**Decision status:** Proposed +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0023 (lineage-criterion anchor) 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 fits independently observed TDT link-criterion -Jeffreys posteriors inside -`analysis_engine::fit_lineage_criterion_posteriors`. Event-time draws remain -producer evidence from the temporal model; the fitter does not infer a date -from record order or promote CHRONOS predictions to observed facts. -Operators still cannot request that runner as a digest-bound analysis-run -output. - -Method-effect labels, template-copy refusals, house-voice refusals, -prompt-boilerplate refusals, non-lexical modality refusals, exhaustive -case-deletion, composed fitted-lineage, Pareto candidate-`K`, and topic -activity remain different profiles. Full Bayesian sampling, GPU, and topic -birth/split/merge remain later GAP-004 work and are not this slice. ADR -0058 through ADR 0061 are already taken by live sibling PRs. +Protected main already fits independently observed TDT link-criterion Jeffreys posteriors through `analysis_engine::fit_lineage_criterion_posteriors`. That fitter owns the criterion arithmetic. Event-time draws are producer evidence; record order and document timestamps are not substitutes for event time. + +The original branch composition exposed four boundary defects before it could truthfully call itself cutoff-safe: pair observations carried no availability provenance, equivalent RFC 3339 spellings were compared as text rather than instants, malformed event-time draws reached the fitter, and pair/draw cardinality was not bounded before posterior materialization. It also copied the domain inference claim into `AnalysisResultSummary.validation_status`, which is provider-authored validation state. + +Method-effect labels, template-copy refusals, house-voice refusals, prompt-boilerplate refusals, non-lexical modality refusals, exhaustive case-deletion, composed fitted-lineage, Pareto candidate-`K`, and topic activity remain different profiles. Full Bayesian sampling, GPU execution, and topic birth/split/merge are outside this slice. + +## Constraints + +- `fit_lineage_criterion_posteriors` remains the scientific-arithmetic owner; this adapter must not duplicate the Jeffreys estimator. +- Event time and evidence availability are different clocks. An event-time draw never proves that its observation was available at the run cutoff. +- Historical replay must ignore same-snapshot observations unavailable until after the requested cutoff before duplicate, event-time, or scientific admission can affect the result. The engine-wide raw-input cardinality bound remains an operational admission limit. +- Cross-snapshot provenance is a hard error rather than historical censoring. +- The terminal validation status describes provider validation. The domain claim `independent_tdt_criterion_not_date_from_record_order` remains on the artifact. +- Resource admission must happen before expensive posterior materialization. ## Decision -Add the `lineage_criterion_v1` analysis-run output profile to -`analysis_engine`. The executor: - -- consumes already-constructed `LineageCriterionObservation` values and a - common draw count; -- requires the request snapshot and knowledge cutoff to match the offered - construction; -- invokes `fit_lineage_criterion_posteriors` without reimplementing Jeffreys - fitting; -- emits a canonical SHA-256-digested `tepp.lineage_criterion.v1` artifact - with pair count, draw count, and inference status - `independent_tdt_criterion_not_date_from_record_order`; -- keeps raw posteriors and pair identities with the scientific fitter rather - than copying them onto the operator artifact; -- refuses reuse of `case_deletion_refit_v1`, `composed_fitted_lineage_v1`, - `fitted_candidate_k_v1`, `pareto_candidate_k_v1`, `trsl_topic_lineage_v1`, - and `method_effects_v1` as this profile; -- does not invent a Bayesian sampler, persist rows, select GPU backends, - infer dates from record order, or emit topic birth/split/merge. - -This is independent TDT link-criterion fitting, not a date inference and -not a posterior sampler. +Add the `lineage_criterion_v1` analysis-run output profile to `analysis_engine` as a thin application/Validation adapter around the protected-main fitter. + +The profile binds every offered `LineageCriterionObservation` to explicit immutable snapshot identity and pair-level `AvailableTime`. The availability value means when the complete pair observation became usable as evidence; producers must not report it earlier than either endpoint's availability. The adapter checks provenance-slice alignment, rejects cross-snapshot rows, and uses `corpus_split::cutoff_eligible` before validating or fitting same-snapshot observations. Future-unavailable rows therefore cannot create duplicate-pair failures, malformed-event-time failures, pair counts, or scientific results for an earlier cutoff. + +Request cutoff binding uses parsed `KnowledgeCutoff::instant()` equality, so equivalent RFC 3339 offsets denote the same cutoff. Admitted predecessor and successor event-time draws must parse as `EventTime`; availability is never inferred from those draws. + +Raw pair population is bounded by `analysis_engine::MAX_EVIDENCE_UNITS`. The admitted pair-count × draw-count materialization budget is bounded to 1,000,000 draw values, matching the workspace's existing bounded posterior-draw scale rather than allowing unbounded CPU/memory work. Fewer than two requested draws fail before fitting. + +The emitted `tepp.lineage_criterion.v1` artifact contains only run/snapshot/cutoff identity, admitted pair count, draw count, and the fixed domain inference claim. Raw pair identities and posterior values remain with the scientific fitter. `AnalysisResultSummary.validation_status` is exactly `validated` on success. + +The 256 KiB `from_json` cap remains an untrusted-input admission boundary. Valid artifact identifiers and counts are bounded tightly enough that canonical output cannot approach that limit; a maximal valid artifact test exercises worst-case JSON escaping rather than retaining an unreachable post-validation egress branch. ## Alternatives considered -1. Bind another method-effect, case-deletion, or composed-lineage profile — - rejected because those binds are already live as separate analysis-run - profiles. -2. Invent a Bayesian sampler or topic birth/split/merge engine — rejected - because those functions do not exist on protected main. -3. Copy raw posteriors or pair identities onto the operator artifact — - rejected because the fitter owns posterior meaning and the analysis-run - contract stays identity-free and bounded. -4. Bind the existing independent-criterion runner to ADR 0022's - analysis-run profile — accepted. - -## Consequences - -Operators can request cutoff-safe independent TDT link-criterion fitting as -a digest-bound terminal result. The artifact does not claim date inference -from record order, CHRONOS promotion, Bayesian sampling, GPU parity, or -topic birth/split/merge. Snapshot/profile/cutoff mismatch, invalid -observations, and fitter refusal fail closed. +1. Keep textual cutoff equality — rejected because RFC 3339 allows different representations of the same instant. +2. Treat event-time draws as evidence availability — rejected because event time and availability are distinct clocks and this would introduce temporal leakage. +3. Validate all rows before applying the cutoff — rejected because future evidence could change a historical replay through duplicate, malformed-time, or fitter refusal paths. +4. Copy or reimplement Jeffreys fitting in the adapter — rejected because `fit_lineage_criterion_posteriors` is the canonical scientific owner. +5. Leave pair/draw work unbounded and rely on allocator failure — rejected because the API accepts externally supplied cardinalities and the estimator materializes posterior draws. +6. Put the domain inference claim in terminal `validation_status` — rejected because that field is provider-authored validation state, not a scientific conclusion. + +## Evidence and effects + +RED `f475a312dde77aa3c316b5249f3eb2b0b036835b` exposes textual cutoff equality and malformed event-time acceptance. Repair `a33ee6807ab843b11d085a28b365fd08b7948810` binds cutoff equality by instant, validates event-time syntax, and separates terminal validation from the domain claim. + +RED `8f4523cf33f75c99a7f3d827745f5580d5139b69` adds explicit provenance, historical replay, cross-snapshot, alignment, and resource-budget contracts. Repair `e6de3aad0ded19eeefeb67a7f9dc1795025d3b1b` admits same-snapshot evidence by availability before scientific validation, bounds posterior materialization, and proves the bounded artifact wire shape. `55edf20de86ac42540c294ad333d20d8cc4d045e` promotes the canonical cutoff helper to a runtime dependency. `1591d7a998659969161b5819eae1cb90844d049c` corrects the integration assertion against the optional terminal summary without changing the production contract. + +These commits are active-PR evidence, not protected-main acceptance. Current-head checks and qualifying review must be regenerated after every source change. ## Verification -The PR includes Rust unit and integration tests for successful pair/draw -counts, empty or invalid observations, criterion refusal, snapshot/profile/ -cutoff mismatch including reuse of live sibling profiles, and artifact -tampering. Run: +The branch carries unit and integration contracts for equivalent cutoff spellings, explicit snapshot/availability provenance, future-unavailable replay invariance, cross-snapshot and provenance-alignment refusal, malformed visible event times, raw/resource limits, scientific fitter refusal, digest binding, artifact tamper resistance, and terminal/domain-claim separation. ```text cargo fmt --all -- --check @@ -84,9 +66,12 @@ cargo clippy -p analysis_engine --all-targets -- -D warnings python3 scripts/validate_documentation.py ``` +## Risks and follow-up + +The application adapter currently receives pair-level availability provenance adjacent to estimator-owned observations. Upstream producers remain responsible for deriving that availability no earlier than both evidence endpoints; a future versioned producer contract may replace the parallel transport with a dedicated pair-evidence value object without changing the scientific fitter. + +ADR 0063 remains Proposed until this composition is inherited by the surviving Analysis Run vehicle, exact-head quality/security gates and coverage pass, and the change reaches protected main. The shared ADR index must not mark this decision Accepted while the implementation is branch-only. + ## Rollback and supersession -Rollback removes the `lineage_criterion_v1` profile. No persisted schema -migration is introduced. Supersede only with an ADR that keeps independent -TDT link-criterion fitting distinct from date inference, CHRONOS -promotion, and Bayesian sampling. +Rollback removes the `lineage_criterion_v1` profile without changing the protected-main scientific fitter or persisted schema. Any superseding ADR must preserve the separation among event time, availability, knowledge cutoff, provider validation, and scientific inference, and must not infer dates from record order or promote CHRONOS predictions to observed facts. From 3003231cf00f272245f0ae114646d424e7abd8cd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:10:03 +0900 Subject: [PATCH 10/13] docs(doctoring): record lineage criterion replay and provenance boundary --- .../lineage-criterion-analysis-run.md | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/doctoring/lineage-criterion-analysis-run.md b/docs/doctoring/lineage-criterion-analysis-run.md index 0ffba33c5..4f229c044 100644 --- a/docs/doctoring/lineage-criterion-analysis-run.md +++ b/docs/doctoring/lineage-criterion-analysis-run.md @@ -1,18 +1,17 @@ # Independent TDT link-criterion analysis-run composition -**Active slice:** ADR 0063 / `lineage_criterion_v1` -**Protected-main status:** not implemented-main +**Active slice:** ADR 0063 / `lineage_criterion_v1` +**Protected-main status:** not implemented-main +**Decision maturity:** Proposed -`analysis_engine` already fits independently observed TDT link-criterion -Jeffreys posteriors through `fit_lineage_criterion_posteriors`. Event-time -draws remain producer evidence. This slice binds that runner to a -cutoff-safe analysis-run profile so an operator can request a digest-bound -terminal result. +`analysis_engine::fit_lineage_criterion_posteriors` on protected main remains the scientific owner for independently observed TDT link-criterion Jeffreys posteriors. The active branch only composes that fitter into a request/receipt/cutoff-bound Analysis Run profile. -The executor does not infer a date from record order and does not promote -CHRONOS predictions to observed facts. Raw posteriors stay with the -scientific fitter. It is not a Bayesian sampler, not GPU execution, and not -topic birth/split/merge. +The composition keeps three temporal facts separate. Predecessor/successor event-time draws remain producer evidence about event occurrence. Pair-level `AvailableTime` says when the complete observation became usable by an analysis. `KnowledgeCutoff` says what the run was allowed to know. Same-snapshot observations unavailable after the cutoff are censored before duplicate, event-time, and fitter admission; cross-snapshot evidence fails closed. Equivalent RFC 3339 spellings are compared by parsed instant rather than text. -Exact-head Checks and two independent approvals are required before any -implemented-main claim. +Visible event-time draws must parse as `EventTime`. The application boundary also limits raw pairs to `MAX_EVIDENCE_UNITS` and admitted pair × draw materialization to 1,000,000 values before invoking the fitter. These are resource/admission controls, not new psychometric arithmetic. + +Successful artifacts keep the domain claim `independent_tdt_criterion_not_date_from_record_order`; terminal `AnalysisResultSummary.validation_status` remains the provider state `validated`. Raw posterior values and pair identities stay with the scientific fitter. + +The branch carries replay tests showing that a same-snapshot future duplicate, even with malformed future event-time content, cannot change an earlier run. It separately tests cross-snapshot and provenance-alignment refusal, malformed visible event times, resource limits, artifact tampering, and digest binding. The 256 KiB inbound artifact cap remains fail closed; maximal-valid output is proven below that bound rather than guarded by an unreachable post-validation branch. + +This profile is a fold child of the surviving Analysis Run consolidation vehicle, not an independent landing claim. Current-head workflow receipts and the repository ruleset's qualifying approval must apply to the exact surviving head; predecessor checks and COMMENTED reviews do not transfer. From 01ce4979e1c19702a188dd96e2f3bb2f81e6f5be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:12:38 +0900 Subject: [PATCH 11/13] fix(analysis): bound lineage draw validation before fitting --- .../src/lineage_criterion_artifact.rs | 85 ++++++++++++++++--- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/crates/analysis_engine/src/lineage_criterion_artifact.rs b/crates/analysis_engine/src/lineage_criterion_artifact.rs index 660ac08e3..87ff96203 100644 --- a/crates/analysis_engine/src/lineage_criterion_artifact.rs +++ b/crates/analysis_engine/src/lineage_criterion_artifact.rs @@ -67,7 +67,7 @@ impl<'a> LineageCriterionInput<'a> { self.snapshot_ids } - /// Borrow evidence availability times aligned to observations. + /// Borrow pair-observation availability times aligned to observations. #[must_use] pub const fn available_times(&self) -> &'a [AvailableTime] { self.available_times @@ -213,7 +213,9 @@ pub fn execute_lineage_criterion_run( { return Err(AnalysisEngineError::InvalidEvidence); } - if input.observations().len() > MAX_EVIDENCE_UNITS { + if input.observations().len() > MAX_EVIDENCE_UNITS + || input.draw_count() > MAX_LINEAGE_CRITERION_DRAW_VALUES + { return Err(AnalysisEngineError::LimitExceeded); } @@ -230,17 +232,14 @@ pub fn execute_lineage_criterion_run( if !cutoff_eligible(available_time, &knowledge_cutoff) { continue; } - if observation - .predecessor_event_time_draws - .iter() - .chain(&observation.successor_event_time_draws) - .any(|value| EventTime::parse_rfc3339(value).is_err()) - { - return Err(AnalysisEngineError::InvalidEvidence); - } + let next_pair_count = admitted_observations + .len() + .checked_add(1) + .ok_or(AnalysisEngineError::LimitExceeded)?; + validate_resource_budget(next_pair_count, input.draw_count())?; + validate_event_time_draws(observation, input.draw_count())?; admitted_observations.push(observation.clone()); } - validate_resource_budget(admitted_observations.len(), input.draw_count())?; let fits = fit_lineage_criterion_posteriors(&admitted_observations, input.draw_count()).map_err( |error| match error { @@ -284,6 +283,31 @@ pub fn execute_lineage_criterion_run( }) } +fn validate_event_time_draws( + observation: &LineageCriterionObservation, + draw_count: usize, +) -> Result<(), AnalysisEngineError> { + if observation.predecessor_event_time_draws.len() != draw_count + || observation.successor_event_time_draws.len() != draw_count + { + return Err(AnalysisEngineError::InvalidEvidence); + } + for (predecessor, successor) in observation + .predecessor_event_time_draws + .iter() + .zip(&observation.successor_event_time_draws) + { + let predecessor = EventTime::parse_rfc3339(predecessor) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + let successor = EventTime::parse_rfc3339(successor) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if predecessor > successor { + return Err(AnalysisEngineError::InvalidEvidence); + } + } + Ok(()) +} + fn validate_resource_budget( pair_count: usize, draw_count: usize, @@ -308,7 +332,7 @@ mod tests { use super::{ LINEAGE_CRITERION_ARTIFACT_BYTE_LIMIT, LINEAGE_CRITERION_ARTIFACT_SCHEMA_VERSION, LINEAGE_CRITERION_INFERENCE_STATUS, LineageCriterionArtifact, LineageCriterionInput, - MAX_LINEAGE_CRITERION_DRAW_VALUES, validate_resource_budget, + MAX_LINEAGE_CRITERION_DRAW_VALUES, validate_event_time_draws, validate_resource_budget, }; use crate::{AnalysisEngineError, LineageCriterionObservation, MAX_EVIDENCE_UNITS}; use temporal_core::AvailableTime; @@ -325,6 +349,16 @@ mod tests { } } + fn observation() -> LineageCriterionObservation { + LineageCriterionObservation { + pair_id: "pair-a".into(), + successes: 1, + trials: 2, + predecessor_event_time_draws: vec!["2026-01-01T00:00:00Z".into(); 2], + successor_event_time_draws: vec!["2026-01-02T00:00:00Z".into(); 2], + } + } + fn assert_invalid(artifact: &LineageCriterionArtifact) { assert_eq!( artifact.to_json(), @@ -414,6 +448,33 @@ mod tests { } } + #[test] + fn event_time_draw_shape_rejects_mismatch_malformed_and_reverse_order() { + let valid = observation(); + assert_eq!(validate_event_time_draws(&valid, 2), Ok(())); + + let mut mismatched = valid.clone(); + mismatched.successor_event_time_draws.pop(); + assert_eq!( + validate_event_time_draws(&mismatched, 2), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut malformed = valid.clone(); + malformed.predecessor_event_time_draws[0] = "not-a-time".into(); + assert_eq!( + validate_event_time_draws(&malformed, 2), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut reversed = valid; + reversed.predecessor_event_time_draws[0] = "2026-01-03T00:00:00Z".into(); + assert_eq!( + validate_event_time_draws(&reversed, 2), + Err(AnalysisEngineError::InvalidEvidence) + ); + } + #[test] fn resource_budget_bounds_pairs_and_materialized_draw_values() { assert_eq!( From a500e4eb6ccebd212f91c6d6d9edf1f274fa1a2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:14:13 +0900 Subject: [PATCH 12/13] docs(adr): trace lineage draw ordering and pre-fit resource admission --- .../adr/0063-lineage-criterion-analysis-run.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/adr/0063-lineage-criterion-analysis-run.md b/docs/adr/0063-lineage-criterion-analysis-run.md index de3fd0f66..762ce223e 100644 --- a/docs/adr/0063-lineage-criterion-analysis-run.md +++ b/docs/adr/0063-lineage-criterion-analysis-run.md @@ -21,8 +21,9 @@ Method-effect labels, template-copy refusals, house-voice refusals, prompt-boile - Event time and evidence availability are different clocks. An event-time draw never proves that its observation was available at the run cutoff. - Historical replay must ignore same-snapshot observations unavailable until after the requested cutoff before duplicate, event-time, or scientific admission can affect the result. The engine-wide raw-input cardinality bound remains an operational admission limit. - Cross-snapshot provenance is a hard error rather than historical censoring. +- Visible predecessor/successor draw vectors must have the requested cardinality and preserve predecessor ≤ successor event-time order for every draw, matching the protected-main lineage-pair producer contract. - The terminal validation status describes provider validation. The domain claim `independent_tdt_criterion_not_date_from_record_order` remains on the artifact. -- Resource admission must happen before expensive posterior materialization. +- Resource admission must happen before expensive timestamp scanning and posterior materialization. ## Decision @@ -30,9 +31,9 @@ Add the `lineage_criterion_v1` analysis-run output profile to `analysis_engine` The profile binds every offered `LineageCriterionObservation` to explicit immutable snapshot identity and pair-level `AvailableTime`. The availability value means when the complete pair observation became usable as evidence; producers must not report it earlier than either endpoint's availability. The adapter checks provenance-slice alignment, rejects cross-snapshot rows, and uses `corpus_split::cutoff_eligible` before validating or fitting same-snapshot observations. Future-unavailable rows therefore cannot create duplicate-pair failures, malformed-event-time failures, pair counts, or scientific results for an earlier cutoff. -Request cutoff binding uses parsed `KnowledgeCutoff::instant()` equality, so equivalent RFC 3339 offsets denote the same cutoff. Admitted predecessor and successor event-time draws must parse as `EventTime`; availability is never inferred from those draws. +Request cutoff binding uses parsed `KnowledgeCutoff::instant()` equality, so equivalent RFC 3339 offsets denote the same cutoff. For each admitted observation, predecessor and successor draw-vector lengths must equal the requested draw count before timestamp scanning; every value must parse as `EventTime`, and every corresponding predecessor instant must be no later than its successor. Availability is never inferred from those draws. -Raw pair population is bounded by `analysis_engine::MAX_EVIDENCE_UNITS`. The admitted pair-count × draw-count materialization budget is bounded to 1,000,000 draw values, matching the workspace's existing bounded posterior-draw scale rather than allowing unbounded CPU/memory work. Fewer than two requested draws fail before fitting. +Raw pair population is bounded by `analysis_engine::MAX_EVIDENCE_UNITS`. The requested draw count is capped before scanning observations, and the admitted pair-count × draw-count materialization budget is bounded to 1,000,000 draw values. The incremental admitted-pair check occurs before event-time parsing and fitter invocation. Fewer than two requested draws fail before fitting. The emitted `tepp.lineage_criterion.v1` artifact contains only run/snapshot/cutoff identity, admitted pair count, draw count, and the fixed domain inference claim. Raw pair identities and posterior values remain with the scientific fitter. `AnalysisResultSummary.validation_status` is exactly `validated` on success. @@ -43,9 +44,10 @@ The 256 KiB `from_json` cap remains an untrusted-input admission boundary. Valid 1. Keep textual cutoff equality — rejected because RFC 3339 allows different representations of the same instant. 2. Treat event-time draws as evidence availability — rejected because event time and availability are distinct clocks and this would introduce temporal leakage. 3. Validate all rows before applying the cutoff — rejected because future evidence could change a historical replay through duplicate, malformed-time, or fitter refusal paths. -4. Copy or reimplement Jeffreys fitting in the adapter — rejected because `fit_lineage_criterion_posteriors` is the canonical scientific owner. -5. Leave pair/draw work unbounded and rely on allocator failure — rejected because the API accepts externally supplied cardinalities and the estimator materializes posterior draws. -6. Put the domain inference claim in terminal `validation_status` — rejected because that field is provider-authored validation state, not a scientific conclusion. +4. Accept parseable but reversed predecessor/successor draw pairs — rejected because the pair ontology is directional and protected-main producer validation already requires predecessor ≤ successor per draw. +5. Copy or reimplement Jeffreys fitting in the adapter — rejected because `fit_lineage_criterion_posteriors` is the canonical scientific owner. +6. Leave pair/draw work unbounded and rely on allocator failure — rejected because the API accepts externally supplied cardinalities and the estimator materializes posterior draws. +7. Put the domain inference claim in terminal `validation_status` — rejected because that field is provider-authored validation state, not a scientific conclusion. ## Evidence and effects @@ -53,11 +55,13 @@ RED `f475a312dde77aa3c316b5249f3eb2b0b036835b` exposes textual cutoff equality a RED `8f4523cf33f75c99a7f3d827745f5580d5139b69` adds explicit provenance, historical replay, cross-snapshot, alignment, and resource-budget contracts. Repair `e6de3aad0ded19eeefeb67a7f9dc1795025d3b1b` admits same-snapshot evidence by availability before scientific validation, bounds posterior materialization, and proves the bounded artifact wire shape. `55edf20de86ac42540c294ad333d20d8cc4d045e` promotes the canonical cutoff helper to a runtime dependency. `1591d7a998659969161b5819eae1cb90844d049c` corrects the integration assertion against the optional terminal summary without changing the production contract. +Follow-up repair `01ce4979e1c19702a188dd96e2f3bb2f81e6f5be` closes the remaining pre-fit resource/temporal shape gap: the global draw ceiling is checked before observation scanning, admitted pair × draw budget is enforced incrementally, mismatched draw vectors are rejected before parsing, and typed predecessor/successor draw pairs must preserve directional event-time order. + These commits are active-PR evidence, not protected-main acceptance. Current-head checks and qualifying review must be regenerated after every source change. ## Verification -The branch carries unit and integration contracts for equivalent cutoff spellings, explicit snapshot/availability provenance, future-unavailable replay invariance, cross-snapshot and provenance-alignment refusal, malformed visible event times, raw/resource limits, scientific fitter refusal, digest binding, artifact tamper resistance, and terminal/domain-claim separation. +The branch carries unit and integration contracts for equivalent cutoff spellings, explicit snapshot/availability provenance, future-unavailable replay invariance, cross-snapshot and provenance-alignment refusal, malformed visible event times, draw shape and directional order, raw/resource limits, scientific fitter refusal, digest binding, artifact tamper resistance, and terminal/domain-claim separation. ```text cargo fmt --all -- --check From 2b666d94df1454157700dafe5edf5448de28d9e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 11:15:21 +0900 Subject: [PATCH 13/13] docs(doctoring): align lineage criterion draw-order and resource gates --- docs/doctoring/lineage-criterion-analysis-run.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/doctoring/lineage-criterion-analysis-run.md b/docs/doctoring/lineage-criterion-analysis-run.md index 4f229c044..99e87dc9a 100644 --- a/docs/doctoring/lineage-criterion-analysis-run.md +++ b/docs/doctoring/lineage-criterion-analysis-run.md @@ -6,12 +6,14 @@ `analysis_engine::fit_lineage_criterion_posteriors` on protected main remains the scientific owner for independently observed TDT link-criterion Jeffreys posteriors. The active branch only composes that fitter into a request/receipt/cutoff-bound Analysis Run profile. -The composition keeps three temporal facts separate. Predecessor/successor event-time draws remain producer evidence about event occurrence. Pair-level `AvailableTime` says when the complete observation became usable by an analysis. `KnowledgeCutoff` says what the run was allowed to know. Same-snapshot observations unavailable after the cutoff are censored before duplicate, event-time, and fitter admission; cross-snapshot evidence fails closed. Equivalent RFC 3339 spellings are compared by parsed instant rather than text. +The composition keeps three temporal facts separate. Predecessor/successor event-time draws remain producer evidence about event occurrence. Pair-level `AvailableTime` says when the complete observation became usable by an analysis. `KnowledgeCutoff` says what the run was allowed to know. Same-snapshot observations unavailable after the cutoff are censored before event-time and scientific admission; cross-snapshot evidence fails closed. Equivalent RFC 3339 spellings are compared by parsed instant rather than text. -Visible event-time draws must parse as `EventTime`. The application boundary also limits raw pairs to `MAX_EVIDENCE_UNITS` and admitted pair × draw materialization to 1,000,000 values before invoking the fitter. These are resource/admission controls, not new psychometric arithmetic. +For every admitted observation, predecessor and successor draw vectors must both match the requested draw count before timestamp scanning. Every visible draw is parsed as `EventTime`, and each directional pair must satisfy predecessor ≤ successor, matching the protected-main lineage-pair producer contract. A future-unavailable malformed or reversed row therefore cannot perturb an earlier replay because availability admission precedes these checks. + +Resource admission also precedes expensive work. Raw pair population remains bounded by `MAX_EVIDENCE_UNITS`; requested draw count is rejected before observation scanning when it exceeds the profile budget; and the admitted pair × draw product is checked incrementally before event-time parsing and posterior fitting. These are application/resource controls, not new psychometric arithmetic. Successful artifacts keep the domain claim `independent_tdt_criterion_not_date_from_record_order`; terminal `AnalysisResultSummary.validation_status` remains the provider state `validated`. Raw posterior values and pair identities stay with the scientific fitter. -The branch carries replay tests showing that a same-snapshot future duplicate, even with malformed future event-time content, cannot change an earlier run. It separately tests cross-snapshot and provenance-alignment refusal, malformed visible event times, resource limits, artifact tampering, and digest binding. The 256 KiB inbound artifact cap remains fail closed; maximal-valid output is proven below that bound rather than guarded by an unreachable post-validation branch. +The branch carries replay tests showing that a same-snapshot future duplicate, even with malformed future event-time content, cannot change an earlier run. It separately tests cross-snapshot and provenance-alignment refusal, malformed visible event times, draw-shape and directional-order refusal, resource limits, artifact tampering, and digest binding. The 256 KiB inbound artifact cap remains fail closed; maximal-valid output is proven below that bound rather than guarded by an unreachable post-validation branch. This profile is a fold child of the surviving Analysis Run consolidation vehicle, not an independent landing claim. Current-head workflow receipts and the repository ruleset's qualifying approval must apply to the exact surviving head; predecessor checks and COMMENTED reviews do not transfer.