From ad44fd872a5fed246898682e7d08dafe51387dc6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 20:31:01 +0900 Subject: [PATCH 1/4] feat(event): persist interval consistency artifacts Signed-off-by: Seongho Bae --- CHANGELOG.md | 1 + Cargo.lock | 3 + crates/event_core/Cargo.toml | 1 + crates/event_core/src/interval_consistency.rs | 21 +- .../src/interval_consistency_artifact.rs | 257 ++++++++++++++++++ crates/event_core/src/lib.rs | 6 + .../interval_consistency_artifact_contract.rs | 231 ++++++++++++++++ crates/persistence_postgres/Cargo.toml | 1 + .../persistence_postgres/src/model_run_sql.rs | 85 +++++- crates/temporal_core/src/reasoner.rs | 8 + crates/tepp_api/Cargo.toml | 1 + crates/tepp_api/src/lib.rs | 4 + docs/TRACEABILITY.md | 2 +- ...tdt-chronos-event-intelligence-boundary.md | 15 +- 14 files changed, 630 insertions(+), 6 deletions(-) create mode 100644 crates/event_core/src/interval_consistency_artifact.rs create mode 100644 crates/event_core/tests/interval_consistency_artifact_contract.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ccf56b7..062a69412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- **TDT/CHRONOS durable result contract**: canonical typed JSON and deterministic GraphML now export the actual bounded Allen reasoner result, observed/derived status, and conservative accepted-assertion support; canonical payload digest and `tdt_chronos_interval_consistency_v1` type bind the immutable bytes into ADR 0013's append-only `model_artifact` chain. - **Analysis engine**: deterministic end-to-end analysis-run execution with cutoff-safe eligibility, immutable evidence binding, and reproducibility manifests (`analysis_engine` crate). - **Restore Driver p.16 `MANIFESTVARstd`**: `recover_standardised_manifest_variance` maps `θ / θ = 1` with strictly positive `MANIFESTVAR`, refusing unstandardised manifest-variance, `MANIFESTTRAITVARstd`, and Equation 5 `Var(y)` substitutions (`psychometric_core`). - **Posterior network estimator**: cross-draw Pearson correlations in ILR space, jackknife SE and CI, Benjamini–Hochberg FDR edge admission, nonparametric bootstrap stability, greedy modularity consensus clustering (`network_analysis` crate). diff --git a/Cargo.lock b/Cargo.lock index 4d5b865b0..454a7d612 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -513,6 +513,7 @@ dependencies = [ "evidence_core", "serde", "serde_json", + "sha2", "temporal_core", "uuid", ] @@ -1193,6 +1194,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" name = "persistence_postgres" version = "0.2.0" dependencies = [ + "event_core", "operational_log", "sqlx", "temporal_core", @@ -1808,6 +1810,7 @@ name = "tepp_api" version = "0.2.0" dependencies = [ "corpus_split", + "event_core", "jiff", "serde", "serde_json", diff --git a/crates/event_core/Cargo.toml b/crates/event_core/Cargo.toml index 1ac11c027..1e282ff90 100644 --- a/crates/event_core/Cargo.toml +++ b/crates/event_core/Cargo.toml @@ -17,6 +17,7 @@ publish = false evidence_core = { path = "../evidence_core", version = "0.2.0" } serde.workspace = true serde_json.workspace = true +sha2.workspace = true temporal_core = { path = "../temporal_core", version = "0.2.0" } uuid.workspace = true diff --git a/crates/event_core/src/interval_consistency.rs b/crates/event_core/src/interval_consistency.rs index 8902a54cc..580fed20a 100644 --- a/crates/event_core/src/interval_consistency.rs +++ b/crates/event_core/src/interval_consistency.rs @@ -13,9 +13,9 @@ use crate::{EventError, EventInstanceId}; use temporal_core::{ - AllenRelation, ClosureReport, ConstraintId, EventTime, RelationSet, TemporalInterval, - TemporalReasoner, TemporalReasonerError, TemporalReasonerLimits, TemporalVariableId, - classify_interval_relation, + AllenRelation, ClosureReport, ConstraintId, DerivedRelation, EventTime, RelationSet, + TemporalInterval, TemporalReasoner, TemporalReasonerError, TemporalReasonerLimits, + TemporalVariableId, classify_interval_relation, }; /// Summary of one successful bounded interval-consistency closure. @@ -166,6 +166,21 @@ impl IntervalConsistencyNetwork { .map(|derived| derived.relations()) .map_err(|error| map_reasoner_error(&error)) } + + /// Return the current relation with observation and support provenance. + /// + /// # Errors + /// + /// Returns an unknown-variable error for identifiers outside this network. + pub fn derived_relation( + &self, + left: TemporalVariableId, + right: TemporalVariableId, + ) -> Result { + self.reasoner + .relation(left, right) + .map_err(|error| map_reasoner_error(&error)) + } } /// Explicit refusal to treat bounded path consistency as unrestricted SAT. diff --git a/crates/event_core/src/interval_consistency_artifact.rs b/crates/event_core/src/interval_consistency_artifact.rs new file mode 100644 index 000000000..2968ef19f --- /dev/null +++ b/crates/event_core/src/interval_consistency_artifact.rs @@ -0,0 +1,257 @@ +//! Durable, digest-bound export of bounded interval-consistency results. + +use crate::{EventError, IntervalConsistencyNetwork}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fmt::Write as _; +use temporal_core::{AllenRelation, RelationSet, TemporalVariableId}; + +/// Model-artifact type used by the ADR-0013 persistence chain. +pub const INTERVAL_CONSISTENCY_ARTIFACT_TYPE: &str = "tdt_chronos_interval_consistency_v1"; +const SCHEMA_VERSION: &str = "tepp.tdt_chronos_interval_consistency.v1"; +const MAX_RELATIONS: usize = 100_000; +const MAX_JSON_BYTES: usize = 4 * 1024 * 1024; + +/// One observed or closure-derived ordered interval relation. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IntervalConsistencyArtifactRelation { + /// Opaque source event identity for the left interval. + pub left_event_id: String, + /// Opaque source event identity for the right interval. + pub right_event_id: String, + /// Remaining Allen relations in stable reasoner order. + pub allen_relations: Vec, + /// Whether this ordered pair has a direct accepted assertion. + pub observed: bool, + /// Accepted-assertion ordinals conservatively supporting this result. + pub support_assertion_ordinals: Vec, +} + +/// Versioned bounded reasoner result suitable for immutable artifact storage. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IntervalConsistencyArtifact { + /// Exact typed schema identity. + pub schema_version: String, + /// Opaque analysis-run identity. + pub run_id: String, + /// Immutable source snapshot identity. + pub snapshot_id: String, + /// Lowercase SHA-256 of the exact admitted input bytes. + pub input_digest_sha256: String, + /// Non-causal observed and closure-derived temporal relations. + pub relations: Vec, +} + +impl IntervalConsistencyArtifact { + /// Project a closed network into a canonical artifact. + /// + /// Universal unconstrained pairs and identity pairs are omitted. + /// + /// # Errors + /// + /// Returns a fail-closed wire or reasoner error for invalid bindings. + pub fn from_network( + run_id: impl Into, + snapshot_id: impl Into, + input_digest_sha256: impl Into, + network: &IntervalConsistencyNetwork, + variables: &[(String, TemporalVariableId)], + ) -> Result { + let mut identities = BTreeSet::new(); + if variables.len() < 2 + || variables + .iter() + .any(|(identity, _)| identity.trim().is_empty() || !identities.insert(identity)) + { + return Err(EventError::InvalidWirePayload); + } + let mut relations = Vec::new(); + for (left_index, (left_identity, left)) in variables.iter().enumerate() { + for (right_identity, right) in variables.iter().skip(left_index + 1) { + let derived = network.derived_relation(*left, *right)?; + if derived.relations() == RelationSet::all() { + continue; + } + relations.push(IntervalConsistencyArtifactRelation { + left_event_id: left_identity.clone(), + right_event_id: right_identity.clone(), + allen_relations: derived.relations().iter().collect(), + observed: derived.is_observed(), + support_assertion_ordinals: derived + .support() + .iter() + .map(|identifier| identifier.assertion_ordinal()) + .collect(), + }); + } + } + relations.sort_by(|left, right| { + (&left.left_event_id, &left.right_event_id) + .cmp(&(&right.left_event_id, &right.right_event_id)) + }); + let artifact = Self { + schema_version: SCHEMA_VERSION.to_owned(), + run_id: run_id.into(), + snapshot_id: snapshot_id.into(), + input_digest_sha256: input_digest_sha256.into(), + relations, + }; + artifact.validate()?; + Ok(artifact) + } + + /// Parse and validate canonical JSON. + /// + /// # Errors + /// + /// Returns [`EventError::InvalidWirePayload`] for malformed input. + pub fn from_json(payload: &str) -> Result { + if payload.len() > MAX_JSON_BYTES { + return Err(EventError::InvalidWirePayload); + } + let artifact: Self = + serde_json::from_str(payload).map_err(|_| EventError::InvalidWirePayload)?; + artifact.validate()?; + if artifact.to_json()? != payload { + return Err(EventError::InvalidWirePayload); + } + Ok(artifact) + } + + /// Serialize canonical validated JSON. + /// + /// # Errors + /// + /// Returns a wire error when fields or size are invalid. + pub fn to_json(&self) -> Result { + self.validate()?; + let payload = serde_json::to_string(self).map_err(|_| EventError::InvalidWirePayload)?; + if payload.len() > MAX_JSON_BYTES { + return Err(EventError::InvalidWirePayload); + } + Ok(payload) + } + + /// Return the lowercase SHA-256 of canonical JSON bytes. + /// + /// # Errors + /// + /// Returns a wire error when the artifact is invalid. + pub fn sha256(&self) -> Result { + Ok(format!("{:x}", Sha256::digest(self.to_json()?.as_bytes()))) + } + + /// Render typed `GraphML` with observation and support provenance. + /// + /// # Errors + /// + /// Returns a wire error when the artifact is invalid. + pub fn to_graphml(&self) -> Result { + self.validate()?; + let mut nodes = BTreeSet::new(); + for relation in &self.relations { + nodes.insert(&relation.left_event_id); + nodes.insert(&relation.right_event_id); + } + let mut output = String::from( + "\n\n\n\n\n\n\n\n\n"); + writeln!( + output, + "{}{}{}", + xml_escape(&self.schema_version), + xml_escape(&self.snapshot_id), + self.input_digest_sha256 + ) + .expect("writing to String cannot fail"); + for node in nodes { + output.push_str("\n"); + } + for (index, relation) in self.relations.iter().enumerate() { + append_edge(&mut output, index, relation); + } + output.push_str("\n\n"); + Ok(output) + } + + fn validate(&self) -> Result<(), EventError> { + if self.schema_version != SCHEMA_VERSION + || self.run_id.trim().is_empty() + || self.snapshot_id.trim().is_empty() + || !valid_digest(&self.input_digest_sha256) + || self.relations.is_empty() + || self.relations.len() > MAX_RELATIONS + { + return Err(EventError::InvalidWirePayload); + } + let mut previous = None; + for relation in &self.relations { + let key = (&relation.left_event_id, &relation.right_event_id); + if relation.left_event_id.trim().is_empty() + || relation.right_event_id.trim().is_empty() + || relation.left_event_id == relation.right_event_id + || relation.allen_relations.is_empty() + || relation.allen_relations.len() == AllenRelation::ALL.len() + || relation.support_assertion_ordinals.is_empty() + || !strictly_increasing(&relation.allen_relations) + || !strictly_increasing(&relation.support_assertion_ordinals) + || previous.is_some_and(|old| old >= key) + { + return Err(EventError::InvalidWirePayload); + } + previous = Some(key); + } + Ok(()) + } +} + +fn append_edge(output: &mut String, index: usize, relation: &IntervalConsistencyArtifactRelation) { + let kinds = relation + .allen_relations + .iter() + .map(|value| serde_json::to_string(value).expect("Allen relation serialization")) + .map(|value| value.trim_matches('"').to_owned()) + .collect::>() + .join(","); + let support = relation + .support_assertion_ordinals + .iter() + .map(usize::to_string) + .collect::>() + .join(","); + writeln!( + output, + "{}{}{support}", + xml_escape(&relation.left_event_id), + xml_escape(&relation.right_event_id), + xml_escape(&kinds), + relation.observed + ) + .expect("writing to String cannot fail"); +} + +fn strictly_increasing(values: &[T]) -> bool { + values.windows(2).all(|pair| pair[0] < pair[1]) +} + +fn valid_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} diff --git a/crates/event_core/src/lib.rs b/crates/event_core/src/lib.rs index 4973ae4ea..374c07b22 100644 --- a/crates/event_core/src/lib.rs +++ b/crates/event_core/src/lib.rs @@ -28,6 +28,7 @@ mod identifier; mod instance; mod intelligence; mod interval_consistency; +mod interval_consistency_artifact; mod link; mod mention; mod prediction; @@ -113,6 +114,11 @@ pub use interval_consistency::IntervalConsistencyReport; pub use interval_consistency::refuse_interval_consistency_as_unrestricted_satisfiability; /// Explicit refusal to promote an interval contradiction into an instance. pub use interval_consistency::refuse_interval_contradiction_as_instance; +/// Durable JSON and `GraphML` projection of one bounded consistency result. +pub use interval_consistency_artifact::{ + INTERVAL_CONSISTENCY_ARTIFACT_TYPE, IntervalConsistencyArtifact, + IntervalConsistencyArtifactRelation, +}; /// TDT same-event versus distinct-event link label. pub use link::EventLinkLabel; /// Undirected TDT link hypothesis between two mentions. diff --git a/crates/event_core/tests/interval_consistency_artifact_contract.rs b/crates/event_core/tests/interval_consistency_artifact_contract.rs new file mode 100644 index 000000000..18cff6fc2 --- /dev/null +++ b/crates/event_core/tests/interval_consistency_artifact_contract.rs @@ -0,0 +1,231 @@ +//! Contract tests for durable bounded interval-consistency exports. + +use event_core::{ + EventError, INTERVAL_CONSISTENCY_ARTIFACT_TYPE, IntervalConsistencyArtifact, + IntervalConsistencyNetwork, +}; +use temporal_core::{AllenRelation, RelationSet}; + +const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn artifact() -> IntervalConsistencyArtifact { + let mut network = IntervalConsistencyNetwork::with_limits(3, 3, 128).expect("limits"); + let first = network.add_variable().expect("first"); + let second = network.add_variable().expect("second"); + let third = network.add_variable().expect("third"); + network + .assert_qualitative_relations(first, second, RelationSet::singleton(AllenRelation::Before)) + .expect("first assertion"); + network + .assert_qualitative_relations(second, third, RelationSet::singleton(AllenRelation::Before)) + .expect("second assertion"); + network.close().expect("closure"); + IntervalConsistencyArtifact::from_network( + "run<&1", + "snapshot-1", + DIGEST, + &network, + &[ + ("event<&1".to_owned(), first), + ("event-2".to_owned(), second), + ("event-3".to_owned(), third), + ], + ) + .expect("artifact") +} + +#[test] +fn derived_branch_is_durable_typed_and_provenance_bearing() { + let artifact = artifact(); + assert_eq!( + INTERVAL_CONSISTENCY_ARTIFACT_TYPE, + "tdt_chronos_interval_consistency_v1" + ); + assert_eq!(artifact.relations.len(), 3); + let derived = artifact + .relations + .iter() + .find(|relation| { + relation.left_event_id == "event<&1" && relation.right_event_id == "event-3" + }) + .expect("derived relation"); + assert_eq!(derived.allen_relations, vec![AllenRelation::Before]); + assert!(!derived.observed); + assert_eq!(derived.support_assertion_ordinals, vec![0, 1]); + + let json = artifact.to_json().expect("json"); + assert_eq!( + IntervalConsistencyArtifact::from_json(&json).expect("round trip"), + artifact + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + let graphml = artifact.to_graphml().expect("graphml"); + assert!(graphml.contains("allen_relations")); + assert!(graphml.contains(&format!("{DIGEST}"))); + assert!(graphml.contains("false")); + assert!(graphml.contains("0,1")); + assert!(graphml.contains("event<&1")); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn artifact_rejects_unbound_or_noncanonical_payloads() { + let mut network = IntervalConsistencyNetwork::with_limits(2, 1, 8).expect("limits"); + let first = network.add_variable().expect("first"); + let second = network.add_variable().expect("second"); + network + .assert_qualitative_relations(first, second, RelationSet::singleton(AllenRelation::Before)) + .expect("assertion"); + assert_eq!( + IntervalConsistencyArtifact::from_network( + "run", + "snapshot", + "not-a-digest", + &network, + &[("same".to_owned(), first), ("same".to_owned(), second)] + ), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + IntervalConsistencyArtifact::from_network( + "run", + "snapshot", + DIGEST, + &network, + &[("event-1".to_owned(), first)] + ), + Err(EventError::InvalidWirePayload) + ); + assert_eq!( + IntervalConsistencyArtifact::from_network( + "run", + "snapshot", + DIGEST, + &network, + &[(String::new(), first), ("event-2".to_owned(), second)] + ), + Err(EventError::InvalidWirePayload) + ); + let json = artifact().to_json().expect("json"); + assert_eq!( + IntervalConsistencyArtifact::from_json(&format!(" {json}")), + Err(EventError::InvalidWirePayload) + ); + let unknown = json.replacen('{', "{\"unknown\":true,", 1); + assert_eq!( + IntervalConsistencyArtifact::from_json(&unknown), + Err(EventError::InvalidWirePayload) + ); + + let valid = artifact(); + let mut invalid_binding = valid.clone(); + invalid_binding.input_digest_sha256 = "bad".to_owned(); + assert_eq!( + invalid_binding.to_json(), + Err(EventError::InvalidWirePayload) + ); + let mut invalid_relation = valid.clone(); + invalid_relation.relations[0] + .support_assertion_ordinals + .clear(); + assert_eq!( + invalid_relation.to_graphml(), + Err(EventError::InvalidWirePayload) + ); + for invalid in invalid_artifact_variants(&valid) { + assert_eq!(invalid.to_json(), Err(EventError::InvalidWirePayload)); + } + let mut numeric_digest = valid.clone(); + numeric_digest.input_digest_sha256 = "1".repeat(64); + numeric_digest + .to_json() + .expect("numeric digest is canonical"); + let mut oversized = valid; + oversized.run_id = "x".repeat(4 * 1024 * 1024); + assert_eq!(oversized.to_json(), Err(EventError::InvalidWirePayload)); + assert_eq!( + IntervalConsistencyArtifact::from_json(&"x".repeat(4 * 1024 * 1024 + 1)), + Err(EventError::InvalidWirePayload) + ); + + let unconstrained = IntervalConsistencyNetwork::with_limits(2, 1, 8).expect("limits"); + let mut foreign = IntervalConsistencyNetwork::with_limits(2, 1, 8).expect("foreign"); + let foreign_left = foreign.add_variable().expect("foreign left"); + let foreign_right = foreign.add_variable().expect("foreign right"); + assert_eq!( + IntervalConsistencyArtifact::from_network( + "run", + "snapshot", + DIGEST, + &unconstrained, + &[ + ("event-1".to_owned(), foreign_left), + ("event-2".to_owned(), foreign_right) + ] + ), + Err(EventError::IntervalConsistencyUnknownVariable) + ); + + let mut quiet = IntervalConsistencyNetwork::with_limits(2, 1, 8).expect("quiet"); + let quiet_left = quiet.add_variable().expect("quiet left"); + let quiet_right = quiet.add_variable().expect("quiet right"); + assert_eq!( + IntervalConsistencyArtifact::from_network( + "run", + "snapshot", + DIGEST, + &quiet, + &[ + ("event-1".to_owned(), quiet_left), + ("event-2".to_owned(), quiet_right) + ] + ), + Err(EventError::InvalidWirePayload) + ); +} + +fn invalid_artifact_variants( + valid: &IntervalConsistencyArtifact, +) -> Vec { + let mut variants = Vec::new(); + let mut invalid = valid.clone(); + "unsupported".clone_into(&mut invalid.schema_version); + variants.push(invalid); + let mut invalid = valid.clone(); + invalid.run_id.clear(); + variants.push(invalid); + let mut invalid = valid.clone(); + invalid.snapshot_id.clear(); + variants.push(invalid); + let mut invalid = valid.clone(); + invalid.relations = vec![valid.relations[0].clone(); 100_001]; + variants.push(invalid); + let mut invalid = valid.clone(); + invalid.relations[0].left_event_id.clear(); + variants.push(invalid); + let mut invalid = valid.clone(); + invalid.relations[0].right_event_id.clear(); + variants.push(invalid); + let mut invalid = valid.clone(); + let left_identity = invalid.relations[0].left_event_id.clone(); + invalid.relations[0] + .right_event_id + .clone_from(&left_identity); + variants.push(invalid); + let mut invalid = valid.clone(); + invalid.relations[0].allen_relations.clear(); + variants.push(invalid); + let mut invalid = valid.clone(); + invalid.relations[0].allen_relations = AllenRelation::ALL.to_vec(); + variants.push(invalid); + let mut invalid = valid.clone(); + invalid.relations[0].allen_relations = vec![AllenRelation::Before, AllenRelation::Before]; + variants.push(invalid); + let mut invalid = valid.clone(); + invalid.relations[0].support_assertion_ordinals = vec![0, 0]; + variants.push(invalid); + let mut invalid = valid.clone(); + invalid.relations.swap(0, 1); + variants.push(invalid); + variants +} diff --git a/crates/persistence_postgres/Cargo.toml b/crates/persistence_postgres/Cargo.toml index c9946a2f5..ac38baab5 100644 --- a/crates/persistence_postgres/Cargo.toml +++ b/crates/persistence_postgres/Cargo.toml @@ -19,6 +19,7 @@ default = [] live-sqlx = ["dep:sqlx", "dep:tokio"] [dependencies] +event_core = { path = "../event_core", version = "0.2.0" } operational_log = { path = "../operational_log", version = "0.2.0" } temporal_core = { path = "../temporal_core", version = "0.2.0" } uuid.workspace = true diff --git a/crates/persistence_postgres/src/model_run_sql.rs b/crates/persistence_postgres/src/model_run_sql.rs index 8e81ca886..d6396bd35 100644 --- a/crates/persistence_postgres/src/model_run_sql.rs +++ b/crates/persistence_postgres/src/model_run_sql.rs @@ -1,6 +1,7 @@ //! SQL contracts for append-only model runs and model artifacts (ADR 0013). use crate::PersistenceError; +use event_core::{INTERVAL_CONSISTENCY_ARTIFACT_TYPE, IntervalConsistencyArtifact}; use temporal_core::{AvailableTime, SystemTime}; use uuid::Uuid; @@ -107,6 +108,41 @@ pub struct ModelArtifactRecord { } impl ModelArtifactRecord { + /// Bind a canonical TDT/CHRONOS artifact to the append-only model-run chain. + /// + /// The protected object reference must resolve to the exact canonical JSON + /// bytes whose digest is stored in this record. + /// + /// # Errors + /// + /// Returns a fail-closed digest error for an invalid artifact or object reference. + #[allow(clippy::too_many_arguments)] + pub fn for_interval_consistency( + model_artifact_id: Uuid, + tenant_record_id: Uuid, + model_run_id: Uuid, + artifact: &IntervalConsistencyArtifact, + protected_object_ref: impl Into, + system_time: SystemTime, + available_time: AvailableTime, + ) -> Result { + let artifact_content_digest = artifact + .sha256() + .map_err(|_| PersistenceError::InvalidContentDigest)?; + let record = Self { + model_artifact_id, + tenant_record_id, + model_run_id, + artifact_type_code: INTERVAL_CONSISTENCY_ARTIFACT_TYPE.to_owned(), + artifact_content_digest, + protected_object_ref: Some(protected_object_ref.into()), + system_time, + available_time, + }; + record.validate()?; + Ok(record) + } + /// Fail-closed field validation for digests and labels. /// /// # Errors @@ -278,7 +314,8 @@ mod tests { validate_sha256_hex, }; use crate::PersistenceError; - use temporal_core::{AvailableTime, SystemTime}; + use event_core::IntervalConsistencyNetwork; + use temporal_core::{AllenRelation, AvailableTime, RelationSet, SystemTime}; use uuid::Uuid; fn sample_times() -> (AvailableTime, SystemTime) { @@ -427,4 +464,50 @@ mod tests { ); assert_eq!(super::escape_literal("a'b"), "a''b"); } + + #[test] + fn interval_consistency_artifact_binds_canonical_bytes_to_model_run() { + let mut network = IntervalConsistencyNetwork::with_limits(2, 1, 8).expect("limits"); + let left = network.add_variable().expect("left"); + let right = network.add_variable().expect("right"); + network + .assert_qualitative_relations( + left, + right, + RelationSet::singleton(AllenRelation::Before), + ) + .expect("assertion"); + let artifact = event_core::IntervalConsistencyArtifact::from_network( + "run-1", + "snapshot-1", + "ab".repeat(32), + &network, + &[("event-1".into(), left), ("event-2".into(), right)], + ) + .expect("artifact"); + let (available_time, system_time) = sample_times(); + let record = ModelArtifactRecord::for_interval_consistency( + Uuid::nil(), + Uuid::nil(), + Uuid::nil(), + &artifact, + "s3://protected/artifact.json", + system_time, + available_time, + ) + .expect("record"); + assert_eq!( + record.artifact_type_code, + event_core::INTERVAL_CONSISTENCY_ARTIFACT_TYPE + ); + assert_eq!( + record.artifact_content_digest, + artifact.sha256().expect("digest") + ); + assert!( + insert_model_artifact_sql(&record) + .expect("sql") + .contains("tdt_chronos") + ); + } } diff --git a/crates/temporal_core/src/reasoner.rs b/crates/temporal_core/src/reasoner.rs index 9f0e88239..185f2c8ee 100644 --- a/crates/temporal_core/src/reasoner.rs +++ b/crates/temporal_core/src/reasoner.rs @@ -28,6 +28,14 @@ pub struct ConstraintId { constraint_index: usize, } +impl ConstraintId { + /// Return the zero-based accepted-assertion ordinal within this reasoner. + #[must_use] + pub const fn assertion_ordinal(self) -> usize { + self.constraint_index + } +} + /// The bounded resource whose configured maximum was exceeded. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ReasonerLimitKind { diff --git a/crates/tepp_api/Cargo.toml b/crates/tepp_api/Cargo.toml index f02445dab..47ad7c433 100644 --- a/crates/tepp_api/Cargo.toml +++ b/crates/tepp_api/Cargo.toml @@ -15,6 +15,7 @@ publish = false [dependencies] corpus_split = { path = "../corpus_split", version = "0.2.0" } +event_core = { path = "../event_core", version = "0.2.0" } jiff = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index b62933209..876703ebc 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -83,6 +83,10 @@ pub use corpus_split_manifest::CorpusSplitPartitions; pub use envelope::ErrorEnvelope; /// Fail-closed API errors. pub use error::ApiError; +/// TDT/CHRONOS model-artifact type for immutable persistence. +pub use event_core::INTERVAL_CONSISTENCY_ARTIFACT_TYPE; +/// Durable typed JSON and `GraphML` bounded-consistency artifact. +pub use event_core::{IntervalConsistencyArtifact, IntervalConsistencyArtifactRelation}; /// Export contract version constant. pub use export::EXPORT_CONTRACT_VERSION; /// Minimal `GraphML` export builder. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 0cd66286c..2b783c2ab 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -79,7 +79,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | 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 | | CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | future `compute_backend` | accepted-target | | CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | future `compute_backend` | accepted-target | -| TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `event_core` TDT link precision/recall on the active PR; remaining TDT/CHRONOS stack and any future `event_intelligence` crate remain accepted-target | active-PR | +| TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `event_core` bounded reasoner plus canonical digest-bound JSON and provenance-bearing GraphML successor artifact; ADR 0013 `model_artifact` persists the immutable object reference/type/digest; JSON-LD remains accepted-target | active-PR | | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | `tepp_api` router plus future `interpretation_gateway` | partial | | TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `event_core` first-story FAR/miss and mention-confidence Brier scoring on the active PR; remaining TDT/CHRONOS stack and any future `event_intelligence` crate remain accepted-target | active-PR | | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | `tepp_api` router implemented-main plus future `interpretation_gateway` | partial | diff --git a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md index 08e34ff28..04499b73d 100644 --- a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md +++ b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md @@ -1,7 +1,7 @@ # ADR 0016 — TDT, CHRONOS, and Event Ontology intelligence boundary **Decision status:** Accepted -**Implementation maturity:** active-PR — versioned TDT/CHRONOS composition is executable in `event_core` (segmentation → span-grounded mentions → links → first-story → tracks → schema slots → forecasts), and bounded Allen/CHRONOS interval consistency is executable via `IntervalConsistencyNetwork` over `temporal_core::TemporalReasoner` (path consistency, quantitative singleton classification, qualitative RelationSet assertions, explicit refusal of unrestricted global satisfiability and of promoting contradictions to instances); persistence and JSON/JSON-LD/GraphML exports remain accepted-target +**Implementation maturity:** active-PR — versioned TDT/CHRONOS composition and bounded Allen/CHRONOS interval consistency are executable in `event_core`; the successor artifact contract exports the actual reasoner result as canonical typed JSON and provenance-bearing GraphML and binds its digest/type to ADR 0013's immutable `model_artifact` chain; JSON-LD remains accepted-target **Date:** 2026-08-12 **Supersedes:** None; complements ADR 0002 temporal semantics and ADR 0003 event ontology/membership. @@ -21,6 +21,17 @@ TEPP separates three event-intelligence layers: Transition edges admitted to the state/input-process-outcome graph remain governed by ADR 0002/0003 and cannot be created merely because TDT/CHRONOS predicts or links two events. Retrospective evidence and schema predictions remain provenance/hypothesis edges until independently promoted. +One completed bounded consistency run is durably published as the canonical +`tepp.tdt_chronos_interval_consistency.v1` JSON payload. It binds an opaque run, +snapshot, exact admitted-input SHA-256, stable Allen relation order, +observed/derived status, and conservative accepted-assertion ordinals. Its +canonical JSON digest and artifact type +`tdt_chronos_interval_consistency_v1` enter ADR 0013's append-only +`model_artifact` row; the protected object reference locates the immutable JSON +bytes. GraphML is a deterministic projection of that same typed payload, not a +second source of truth and not a causal-transition claim. Loaders must verify +the object bytes against the stored digest before decoding. + The bounded `prediction_contradiction` crate is the promotion-authority gate for a pairwise predicted-versus-observed closed proper interval. `refuse_promotion` and `require_observed_coverage` succeed only when later-available evidence covers every predicted instant (`during`, `starts`, `finishes`, or `equals`). `refuse_contradiction_or_adjacency` answers only whether the pair is Allen `before`/`after` or `meets`/`met_by`; its `Ok(())` is not authority to promote unmatched predicted mass. ## Alternatives considered @@ -36,6 +47,8 @@ The bounded `prediction_contradiction` crate is the promotion-authority gate for - temporal-consistency failures can reject a proposed schema/transition without rewriting source evidence; - TDT/CHRONOS outputs can feed psychometric and longitudinal models only through versioned, uncertainty-bearing contracts; - event prediction is never silently converted to historical fact. +- a consumer can replay the exact observed/derived temporal result and its + conservative support without inspecting reasoner-private identifiers. ## Failure and recovery From 17a91ce5625e7a52f0d736e84b70a41864625d09 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 20:45:06 +0900 Subject: [PATCH 2/4] fix(event): preserve reverse observation evidence --- .../src/interval_consistency_artifact.rs | 6 ++- .../interval_consistency_artifact_contract.rs | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/event_core/src/interval_consistency_artifact.rs b/crates/event_core/src/interval_consistency_artifact.rs index 2968ef19f..d547cc4bc 100644 --- a/crates/event_core/src/interval_consistency_artifact.rs +++ b/crates/event_core/src/interval_consistency_artifact.rs @@ -75,11 +75,14 @@ impl IntervalConsistencyArtifact { if derived.relations() == RelationSet::all() { continue; } + let inverse = network.derived_relation(*right, *left)?; relations.push(IntervalConsistencyArtifactRelation { left_event_id: left_identity.clone(), right_event_id: right_identity.clone(), allen_relations: derived.relations().iter().collect(), - observed: derived.is_observed(), + // Observation is orientation-independent even though the + // export retains the stable variable ordering. + observed: derived.is_observed() || inverse.is_observed(), support_assertion_ordinals: derived .support() .iter() @@ -100,6 +103,7 @@ impl IntervalConsistencyArtifact { relations, }; artifact.validate()?; + let _canonical_json = artifact.to_json()?; Ok(artifact) } diff --git a/crates/event_core/tests/interval_consistency_artifact_contract.rs b/crates/event_core/tests/interval_consistency_artifact_contract.rs index 18cff6fc2..3909fbc84 100644 --- a/crates/event_core/tests/interval_consistency_artifact_contract.rs +++ b/crates/event_core/tests/interval_consistency_artifact_contract.rs @@ -67,6 +67,33 @@ fn derived_branch_is_durable_typed_and_provenance_bearing() { assert!(graphml.contains("event<&1")); } +#[test] +fn reverse_oriented_assertion_is_observed_in_stable_export_order() { + let mut network = IntervalConsistencyNetwork::with_limits(2, 1, 8).expect("limits"); + let left = network.add_variable().expect("left"); + let right = network.add_variable().expect("right"); + network + .assert_qualitative_relations(right, left, RelationSet::singleton(AllenRelation::After)) + .expect("reverse assertion"); + let artifact = IntervalConsistencyArtifact::from_network( + "run-reverse", + "snapshot-reverse", + DIGEST, + &network, + &[ + ("event-left".to_owned(), left), + ("event-right".to_owned(), right), + ], + ) + .expect("artifact"); + + assert!(artifact.relations[0].observed); + assert_eq!( + artifact.relations[0].allen_relations, + vec![AllenRelation::Before] + ); +} + #[test] #[allow(clippy::too_many_lines)] fn artifact_rejects_unbound_or_noncanonical_payloads() { @@ -143,6 +170,19 @@ fn artifact_rejects_unbound_or_noncanonical_payloads() { let mut oversized = valid; oversized.run_id = "x".repeat(4 * 1024 * 1024); assert_eq!(oversized.to_json(), Err(EventError::InvalidWirePayload)); + assert_eq!( + IntervalConsistencyArtifact::from_network( + "x".repeat(4 * 1024 * 1024), + "snapshot", + DIGEST, + &network, + &[ + ("event-1".to_owned(), first), + ("event-2".to_owned(), second) + ] + ), + Err(EventError::InvalidWirePayload) + ); assert_eq!( IntervalConsistencyArtifact::from_json(&"x".repeat(4 * 1024 * 1024 + 1)), Err(EventError::InvalidWirePayload) From 31e6bc5b622f433ddd36f4926178cb5ea1cd54f0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 20:51:07 +0900 Subject: [PATCH 3/4] fix(event): canonicalize artifact variable order --- .../src/interval_consistency_artifact.rs | 6 ++- .../interval_consistency_artifact_contract.rs | 37 +++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/event_core/src/interval_consistency_artifact.rs b/crates/event_core/src/interval_consistency_artifact.rs index d547cc4bc..7a685ecfb 100644 --- a/crates/event_core/src/interval_consistency_artifact.rs +++ b/crates/event_core/src/interval_consistency_artifact.rs @@ -68,9 +68,11 @@ impl IntervalConsistencyArtifact { { return Err(EventError::InvalidWirePayload); } + let mut ordered_variables = variables.iter().collect::>(); + ordered_variables.sort_by(|left, right| left.0.cmp(&right.0)); let mut relations = Vec::new(); - for (left_index, (left_identity, left)) in variables.iter().enumerate() { - for (right_identity, right) in variables.iter().skip(left_index + 1) { + for (left_index, (left_identity, left)) in ordered_variables.iter().enumerate() { + for (right_identity, right) in ordered_variables.iter().skip(left_index + 1) { let derived = network.derived_relation(*left, *right)?; if derived.relations() == RelationSet::all() { continue; diff --git a/crates/event_core/tests/interval_consistency_artifact_contract.rs b/crates/event_core/tests/interval_consistency_artifact_contract.rs index 3909fbc84..bf3984b02 100644 --- a/crates/event_core/tests/interval_consistency_artifact_contract.rs +++ b/crates/event_core/tests/interval_consistency_artifact_contract.rs @@ -26,7 +26,7 @@ fn artifact() -> IntervalConsistencyArtifact { DIGEST, &network, &[ - ("event<&1".to_owned(), first), + ("event-1<&".to_owned(), first), ("event-2".to_owned(), second), ("event-3".to_owned(), third), ], @@ -46,7 +46,7 @@ fn derived_branch_is_durable_typed_and_provenance_bearing() { .relations .iter() .find(|relation| { - relation.left_event_id == "event<&1" && relation.right_event_id == "event-3" + relation.left_event_id == "event-1<&" && relation.right_event_id == "event-3" }) .expect("derived relation"); assert_eq!(derived.allen_relations, vec![AllenRelation::Before]); @@ -64,7 +64,7 @@ fn derived_branch_is_durable_typed_and_provenance_bearing() { assert!(graphml.contains(&format!("{DIGEST}"))); assert!(graphml.contains("false")); assert!(graphml.contains("0,1")); - assert!(graphml.contains("event<&1")); + assert!(graphml.contains("event-1<&")); } #[test] @@ -94,6 +94,37 @@ fn reverse_oriented_assertion_is_observed_in_stable_export_order() { ); } +#[test] +fn caller_variable_order_does_not_change_canonical_artifact() { + let expected = artifact(); + let mut network = IntervalConsistencyNetwork::with_limits(3, 3, 128).expect("limits"); + let first = network.add_variable().expect("first"); + let second = network.add_variable().expect("second"); + let third = network.add_variable().expect("third"); + network + .assert_qualitative_relations(first, second, RelationSet::singleton(AllenRelation::Before)) + .expect("first assertion"); + network + .assert_qualitative_relations(second, third, RelationSet::singleton(AllenRelation::Before)) + .expect("second assertion"); + network.close().expect("closure"); + let reordered = IntervalConsistencyArtifact::from_network( + "run<&1", + "snapshot-1", + DIGEST, + &network, + &[ + ("event-3".to_owned(), third), + ("event-1<&".to_owned(), first), + ("event-2".to_owned(), second), + ], + ) + .expect("reordered artifact"); + + assert_eq!(reordered, expected); + assert_eq!(reordered.sha256(), expected.sha256()); +} + #[test] #[allow(clippy::too_many_lines)] fn artifact_rejects_unbound_or_noncanonical_payloads() { From 4f0223cb28bd419ff22fef692da910ec63f8eb69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 20:57:09 +0900 Subject: [PATCH 4/4] docs(event): clarify observation orientation --- crates/event_core/src/interval_consistency_artifact.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/event_core/src/interval_consistency_artifact.rs b/crates/event_core/src/interval_consistency_artifact.rs index 7a685ecfb..874224e03 100644 --- a/crates/event_core/src/interval_consistency_artifact.rs +++ b/crates/event_core/src/interval_consistency_artifact.rs @@ -23,7 +23,7 @@ pub struct IntervalConsistencyArtifactRelation { pub right_event_id: String, /// Remaining Allen relations in stable reasoner order. pub allen_relations: Vec, - /// Whether this ordered pair has a direct accepted assertion. + /// Whether either orientation of this interval pair has a direct accepted assertion. pub observed: bool, /// Accepted-assertion ordinals conservatively supporting this result. pub support_assertion_ordinals: Vec,