From 5679b9488e01182a2116a8eb038f8c8b125f33cc Mon Sep 17 00:00:00 2001 From: seonghobae Date: Thu, 3 Sep 2026 00:21:53 +0000 Subject: [PATCH 1/5] feat(analysis): bind prediction-contradiction refusals to an analysis-run profile GAP-004: cutoff-safe prediction_contradiction_v1 / tepp.prediction_contradiction.v1. Binds refuse_promotion already on protected main. Distinct from support-edge, summarizes-edge, retrospective-edge, role-contradiction, subevent-containment, inferred-status, episode-membership, relation-absence, outcome-order, membership-target, citation-edge, and copy-identity. contradiction_agreement_rate stays library-side. AvailableTime cutoff runs before duplicate checks; MAX_EVIDENCE_UNITS bounds execution and artifact validation. --- CHANGELOG.md | 2 + Cargo.lock | 1 + DOCUMENTATION.md | 1 + crates/analysis_engine/Cargo.toml | 1 + crates/analysis_engine/src/lib.rs | 17 + .../src/prediction_contradiction_artifact.rs | 504 ++++++++++++++++++ ...contradiction_cutoff_semantics_contract.rs | 79 +++ ...iction_contradiction_execution_contract.rs | 352 ++++++++++++ docs/TRACEABILITY.md | 1 + ...9-prediction-contradiction-analysis-run.md | 105 ++++ docs/adr/README.md | 2 + .../prediction-contradiction-analysis-run.md | 18 + 12 files changed, 1083 insertions(+) create mode 100644 crates/analysis_engine/src/prediction_contradiction_artifact.rs create mode 100644 crates/analysis_engine/tests/prediction_contradiction_cutoff_semantics_contract.rs create mode 100644 crates/analysis_engine/tests/prediction_contradiction_execution_contract.rs create mode 100644 docs/adr/0079-prediction-contradiction-analysis-run.md create mode 100644 docs/doctoring/prediction-contradiction-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..6a8bc427b 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] +- **Prediction-contradiction analysis-run profile**: `analysis_engine` binds existing `prediction_contradiction::refuse_promotion` to cutoff-safe `prediction_contradiction_v1` (`tepp.prediction_contradiction.v1`) with inference status `unmatched_prediction_is_not_observed`. `contradiction_agreement_rate` stays library-side. Not support-edge, not summarizes-edge, not retrospective-edge, not role-contradiction, not subevent-containment, not inferred-status, not episode-membership, not relation-absence, not outcome-order, not membership-target, not location-membership, not membership-posterior ICC, not copied-text, not copy-identity, not citation-edge, not GPU, not MCMC, and not topic birth/split/merge. + - `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/Cargo.lock b/Cargo.lock index 454a7d612..47be8f832 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,7 @@ dependencies = [ "corpus_split", "event_core", "membership_core", + "prediction_contradiction", "relation_graph", "serde", "serde_json", diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..19acf3078 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -71,6 +71,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | | Analysis engine v1 doctoring | [`docs/doctoring/analysis-engine-v1.md`](docs/doctoring/analysis-engine-v1.md) | | Analysis engine gap-closure doctoring | [`docs/doctoring/analysis-engine-gap-closure.md`](docs/doctoring/analysis-engine-gap-closure.md) | +| Prediction-contradiction analysis-run doctoring | [`docs/doctoring/prediction-contradiction-analysis-run.md`](docs/doctoring/prediction-contradiction-analysis-run.md) | | Corpus-split leakage-audit wire doctoring | [`docs/research/corpus-split-manifest-wire.md`](docs/research/corpus-split-manifest-wire.md) | | Unicode canonical-identity doctoring | [`docs/research/unicode-canonical-identity.md`](docs/research/unicode-canonical-identity.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 7322212b2..0e92169e4 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -15,6 +15,7 @@ publish = false [dependencies] event_core = { path = "../event_core", version = "0.2.0" } +prediction_contradiction = { path = "../prediction_contradiction", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..8331ec37c 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -12,6 +12,7 @@ mod case_deletion_refit; mod lineage_criterion; +mod prediction_contradiction_artifact; mod topic_context_posterior; mod topic_lineage_artifact; @@ -46,6 +47,13 @@ pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, fit_lineage_criterion_posteriors, }; +/// Prediction-contradiction artifact and execution contracts from this engine. +pub use prediction_contradiction_artifact::{ + PREDICTION_CONTRADICTION_ARTIFACT_BYTE_LIMIT, PREDICTION_CONTRADICTION_ARTIFACT_SCHEMA_VERSION, + PREDICTION_CONTRADICTION_MODEL_CONTRACT_VERSION, PREDICTION_CONTRADICTION_OUTPUT_PROFILE, + PredictionContradictionArtifact, PredictionContradictionAssignment, + PredictionContradictionExecution, execute_prediction_contradiction_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 +256,8 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// A prediction-contradiction artifact violated its bounded schema or count invariants. + InvalidPredictionContradictionArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +272,9 @@ 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::InvalidPredictionContradictionArtifact => { + "invalid prediction-contradiction artifact" + } }; formatter.write_str(message) } @@ -681,6 +694,10 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidPredictionContradictionArtifact, + "invalid prediction-contradiction artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/src/prediction_contradiction_artifact.rs b/crates/analysis_engine/src/prediction_contradiction_artifact.rs new file mode 100644 index 000000000..a6d22268f --- /dev/null +++ b/crates/analysis_engine/src/prediction_contradiction_artifact.rs @@ -0,0 +1,504 @@ +//! Digest-bound prediction-contradiction refusals as an analysis-run profile. + +use std::collections::BTreeSet; + +use prediction_contradiction::{PredictionContradictionError, refuse_promotion}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::{AvailableTime, EventTime, KnowledgeCutoff, TemporalInterval}; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, format_digest, require_receipt_identity, + valid_identifier, +}; + +/// Versioned schema for a completed prediction-contradiction artifact. +pub const PREDICTION_CONTRADICTION_ARTIFACT_SCHEMA_VERSION: &str = + "tepp.prediction_contradiction.v1"; +/// Model contract required by the prediction-contradiction execution path. +pub const PREDICTION_CONTRADICTION_MODEL_CONTRACT_VERSION: &str = "prediction_contradiction_v1"; +/// Analysis-run output profile required for a prediction-contradiction artifact. +pub const PREDICTION_CONTRADICTION_OUTPUT_PROFILE: &str = "prediction_contradiction_v1"; +/// Maximum canonical artifact JSON size. +pub const PREDICTION_CONTRADICTION_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const PREDICTION_CONTRADICTION_INFERENCE_STATUS: &str = "unmatched_prediction_is_not_observed"; + +/// One cutoff-admitted predicted-versus-observed interval pair. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PredictionContradictionAssignment { + assignment_id: String, + predicted: TemporalInterval, + observed: TemporalInterval, + available_time: AvailableTime, +} + +impl PredictionContradictionAssignment { + /// Construct a bounded prediction-contradiction assignment. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the assignment + /// identity is empty or oversized. + pub fn new( + assignment_id: impl Into, + predicted: TemporalInterval, + observed: TemporalInterval, + available_time: AvailableTime, + ) -> Result { + let assignment_id = assignment_id.into(); + if !valid_identifier(&assignment_id) { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + assignment_id, + predicted, + observed, + available_time, + }) + } + + /// Return the opaque assignment identity. + #[must_use] + pub fn assignment_id(&self) -> &str { + &self.assignment_id + } + + /// Return the predicted event-time interval. + #[must_use] + pub const fn predicted(&self) -> TemporalInterval { + self.predicted + } + + /// Return the observed event-time interval. + #[must_use] + pub const fn observed(&self) -> TemporalInterval { + self.observed + } + + /// Return the availability time used for cutoff eligibility. + #[must_use] + pub const fn available_time(&self) -> AvailableTime { + self.available_time + } +} + +/// Completed, bounded prediction-contradiction census for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PredictionContradictionArtifact { + /// 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 to admit assignments. + pub knowledge_cutoff: String, + /// Number of assignments admitted at the cutoff. + pub assignment_count: u64, + /// Predicted intervals covered by later-observed evidence. + pub covered_count: u64, + /// Predicted intervals with interior overlap but unmatched mass. + pub partial_overlap_count: u64, + /// Predicted intervals that only meet observed evidence. + pub adjacent_count: u64, + /// Predicted intervals disjoint from observed evidence. + pub contradictory_count: u64, + /// Predicted intervals refused as observed fact. + pub refused_promotion_count: u64, + /// Fixed claim boundary for operator copy. + pub inference_status: String, +} + +impl PredictionContradictionArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidPredictionContradictionArtifact`] + /// when the schema, identifiers, counts, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > PREDICTION_CONTRADICTION_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidPredictionContradictionArtifact)?; + 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() > PREDICTION_CONTRADICTION_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> { + let kind_sum = self + .covered_count + .checked_add(self.partial_overlap_count) + .and_then(|sum| sum.checked_add(self.adjacent_count)) + .and_then(|sum| sum.checked_add(self.contradictory_count)); + let refused_sum = self + .partial_overlap_count + .checked_add(self.adjacent_count) + .and_then(|sum| sum.checked_add(self.contradictory_count)); + if self.schema_version != PREDICTION_CONTRADICTION_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.assignment_count < 4 + || self.assignment_count > MAX_EVIDENCE_UNITS as u64 + || self.covered_count == 0 + || self.partial_overlap_count == 0 + || self.adjacent_count == 0 + || self.contradictory_count == 0 + || kind_sum != Some(self.assignment_count) + || refused_sum != Some(self.refused_promotion_count) + || self.refused_promotion_count.checked_add(self.covered_count) + != Some(self.assignment_count) + || self.inference_status != PREDICTION_CONTRADICTION_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidPredictionContradictionArtifact); + } + Ok(()) + } +} + +/// One completed prediction-contradiction artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct PredictionContradictionExecution { + /// Digest-bound completed prediction-contradiction census. + pub artifact: PredictionContradictionArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute cutoff-safe prediction-contradiction refusals as one analysis-run +/// profile. +/// +/// The executor invokes [`refuse_promotion`] already on protected main. +/// Covered pairs may authorize promotion. Partial overlap, adjacency, and +/// Allen `before` / `after` stay hypothetical. `contradiction_agreement_rate` +/// stays library-side. It does not emit a `scientific_acceptance` inspect +/// metric, GPU kernels, MCMC, or topic birth/split/merge events. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, empty or +/// incomplete mixed-kind corpus, missing refusal, duplicate assignment +/// identity, oversized corpus, or invalid artifact error. +pub fn execute_prediction_contradiction_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + assignments: &[PredictionContradictionAssignment], + 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); + } + let request_cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if request_cutoff.instant() != knowledge_cutoff.instant() + || request.model_contract_version != PREDICTION_CONTRADICTION_MODEL_CONTRACT_VERSION + || request.output_profile != PREDICTION_CONTRADICTION_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + if assignments.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + + let census = census_admitted_assignments(assignments, knowledge_cutoff)?; + let artifact = PredictionContradictionArtifact { + schema_version: PREDICTION_CONTRADICTION_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + assignment_count: census.assignment_count, + covered_count: census.covered_count, + partial_overlap_count: census.partial_overlap_count, + adjacent_count: census.adjacent_count, + contradictory_count: census.contradictory_count, + refused_promotion_count: census.refused_promotion_count, + inference_status: PREDICTION_CONTRADICTION_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "prediction_contradiction", + census.assignment_count, + 4, + "validated", + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("prediction_contradiction_artifact_{}", &digest[..16]), + digest, + PREDICTION_CONTRADICTION_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(PredictionContradictionExecution { + artifact, + terminal_result, + }) +} + +#[allow(clippy::struct_field_names)] +struct PredictionContradictionCensus { + assignment_count: u64, + covered_count: u64, + partial_overlap_count: u64, + adjacent_count: u64, + contradictory_count: u64, + refused_promotion_count: u64, +} + +fn census_admitted_assignments( + assignments: &[PredictionContradictionAssignment], + knowledge_cutoff: KnowledgeCutoff, +) -> Result { + let mut seen = BTreeSet::new(); + let mut covered_count = 0_u64; + let mut partial_overlap_count = 0_u64; + let mut adjacent_count = 0_u64; + let mut contradictory_count = 0_u64; + let mut refused_promotion_count = 0_u64; + for assignment in assignments { + if assignment.available_time().instant() > knowledge_cutoff.instant() { + continue; + } + if !seen.insert(assignment.assignment_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + classify_assignment( + assignment, + knowledge_cutoff, + &mut covered_count, + &mut partial_overlap_count, + &mut adjacent_count, + &mut contradictory_count, + &mut refused_promotion_count, + )?; + } + + let assignment_count = covered_count + .checked_add(partial_overlap_count) + .and_then(|sum| sum.checked_add(adjacent_count)) + .and_then(|sum| sum.checked_add(contradictory_count)) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + if assignment_count < 4 + || covered_count == 0 + || partial_overlap_count == 0 + || adjacent_count == 0 + || contradictory_count == 0 + || refused_promotion_count + .checked_add(covered_count) + .ok_or(AnalysisEngineError::ArithmeticOverflow)? + != assignment_count + { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(PredictionContradictionCensus { + assignment_count, + covered_count, + partial_overlap_count, + adjacent_count, + contradictory_count, + refused_promotion_count, + }) +} + +fn classify_assignment( + assignment: &PredictionContradictionAssignment, + knowledge_cutoff: KnowledgeCutoff, + covered_count: &mut u64, + partial_overlap_count: &mut u64, + adjacent_count: &mut u64, + contradictory_count: &mut u64, + refused_promotion_count: &mut u64, +) -> Result<(), AnalysisEngineError> { + match refuse_promotion( + &assignment.predicted(), + &assignment.observed(), + assignment.available_time(), + knowledge_cutoff, + ) { + Ok(()) => *covered_count = increment(*covered_count)?, + Err(PredictionContradictionError::PredictionNotCoveredByObservation) => { + *partial_overlap_count = increment(*partial_overlap_count)?; + *refused_promotion_count = increment(*refused_promotion_count)?; + } + Err(PredictionContradictionError::PredictionLacksOverlappingSupport) => { + *adjacent_count = increment(*adjacent_count)?; + *refused_promotion_count = increment(*refused_promotion_count)?; + } + Err(PredictionContradictionError::PredictionContradictsObservation) => { + *contradictory_count = increment(*contradictory_count)?; + *refused_promotion_count = increment(*refused_promotion_count)?; + } + _ => return Err(AnalysisEngineError::InvalidEvidence), + } + Ok(()) +} + +fn increment(count: u64) -> Result { + count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow) +} + +#[cfg(test)] +mod tests { + use super::{ + PREDICTION_CONTRADICTION_ARTIFACT_BYTE_LIMIT, + PREDICTION_CONTRADICTION_ARTIFACT_SCHEMA_VERSION, + PREDICTION_CONTRADICTION_INFERENCE_STATUS, PredictionContradictionArtifact, + }; + use crate::AnalysisEngineError; + + fn artifact() -> PredictionContradictionArtifact { + PredictionContradictionArtifact { + schema_version: PREDICTION_CONTRADICTION_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + assignment_count: 4, + covered_count: 1, + partial_overlap_count: 1, + adjacent_count: 1, + contradictory_count: 1, + refused_promotion_count: 3, + inference_status: PREDICTION_CONTRADICTION_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &PredictionContradictionArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidPredictionContradictionArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + PredictionContradictionArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + PredictionContradictionArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidPredictionContradictionArtifact) + ); + assert_eq!( + PredictionContradictionArtifact::from_json( + &"x".repeat(PREDICTION_CONTRADICTION_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.assignment_count = 3; + value + }, + { + let mut value = artifact.clone(); + value.covered_count = 0; + value.assignment_count = 3; + value.refused_promotion_count = 3; + value + }, + { + let mut value = artifact.clone(); + value.partial_overlap_count = 0; + value.assignment_count = 3; + value.refused_promotion_count = 2; + value + }, + { + let mut value = artifact.clone(); + value.adjacent_count = 0; + value.assignment_count = 3; + value.refused_promotion_count = 2; + value + }, + { + let mut value = artifact.clone(); + value.contradictory_count = 0; + value.assignment_count = 3; + value.refused_promotion_count = 2; + value + }, + { + let mut value = artifact.clone(); + value.refused_promotion_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.inference_status.clear(); + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } +} diff --git a/crates/analysis_engine/tests/prediction_contradiction_cutoff_semantics_contract.rs b/crates/analysis_engine/tests/prediction_contradiction_cutoff_semantics_contract.rs new file mode 100644 index 000000000..b1921a290 --- /dev/null +++ b/crates/analysis_engine/tests/prediction_contradiction_cutoff_semantics_contract.rs @@ -0,0 +1,79 @@ +//! Regression contract for semantic knowledge-cutoff equality in prediction-contradiction runs. + +use analysis_engine::{ + PREDICTION_CONTRADICTION_MODEL_CONTRACT_VERSION, PREDICTION_CONTRADICTION_OUTPUT_PROFILE, + PredictionContradictionAssignment, execute_prediction_contradiction_run, +}; +use temporal_core::{ + AvailableTime, EventTime, KnowledgeCutoff, TemporalBoundary, TemporalInterval, + TemporalPrecision, +}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn available() -> AvailableTime { + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available time") +} + +fn event_at(second: u8) -> EventTime { + EventTime::parse_rfc3339(&format!("2026-01-01T00:00:{second:02}Z")).expect("event time") +} + +fn closed(start: u8, end: u8) -> TemporalInterval { + TemporalInterval::bounded( + TemporalBoundary::Included(event_at(start)), + TemporalBoundary::Included(event_at(end)), + TemporalPrecision::Second, + ) + .expect("closed interval") +} + +fn assignment( + assignment_id: &str, + predicted: TemporalInterval, + observed: TemporalInterval, +) -> PredictionContradictionAssignment { + PredictionContradictionAssignment::new(assignment_id, predicted, observed, available()) + .expect("assignment") +} + +#[test] +fn equivalent_rfc3339_cutoff_offsets_are_admitted() { + let request = AnalysisRunRequest { + contract_version: 1, + idempotency_key: "prediction-contradiction-equivalent-cutoff".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-prediction-contradiction".into(), + knowledge_cutoff: "2026-08-01T09:00:00+09:00".into(), + model_contract_version: PREDICTION_CONTRADICTION_MODEL_CONTRACT_VERSION.into(), + output_profile: PREDICTION_CONTRADICTION_OUTPUT_PROFILE.into(), + }; + let accepted = AnalysisRunAccepted::new( + "run-prediction-contradiction", + "accepted", + &request.idempotency_key, + ) + .expect("accepted"); + let execution_cutoff = + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("execution cutoff"); + let assignments = vec![ + assignment("covered-a", closed(0, 8), closed(0, 10)), + assignment("partial-b", closed(0, 10), closed(5, 15)), + assignment("adjacent-c", closed(0, 10), closed(10, 20)), + assignment("contradictory-d", closed(0, 10), closed(20, 30)), + ]; + + let execution = execute_prediction_contradiction_run( + &request, + &accepted, + "snapshot-prediction-contradiction", + execution_cutoff, + &assignments, + "2026-08-02T00:00:00Z", + ) + .expect("equivalent RFC 3339 spellings denote the same cutoff instant"); + + assert_eq!( + execution.artifact.knowledge_cutoff, + execution_cutoff.to_rfc3339() + ); +} diff --git a/crates/analysis_engine/tests/prediction_contradiction_execution_contract.rs b/crates/analysis_engine/tests/prediction_contradiction_execution_contract.rs new file mode 100644 index 000000000..45345c48d --- /dev/null +++ b/crates/analysis_engine/tests/prediction_contradiction_execution_contract.rs @@ -0,0 +1,352 @@ +//! End-to-end contract for cutoff-safe prediction-contradiction refusals. + +use analysis_engine::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, PREDICTION_CONTRADICTION_ARTIFACT_SCHEMA_VERSION, + PREDICTION_CONTRADICTION_MODEL_CONTRACT_VERSION, PREDICTION_CONTRADICTION_OUTPUT_PROFILE, + PredictionContradictionArtifact, PredictionContradictionAssignment, + execute_prediction_contradiction_run, +}; +use temporal_core::{ + AvailableTime, EventTime, KnowledgeCutoff, TemporalBoundary, TemporalInterval, + TemporalPrecision, +}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn event_at(second: u8) -> EventTime { + EventTime::parse_rfc3339(&format!("2026-01-01T00:00:{second:02}Z")).expect("event time") +} + +fn closed(start: u8, end: u8) -> TemporalInterval { + TemporalInterval::bounded( + TemporalBoundary::Included(event_at(start)), + TemporalBoundary::Included(event_at(end)), + TemporalPrecision::Second, + ) + .expect("closed interval") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "prediction-contradiction-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-prediction-contradiction".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: PREDICTION_CONTRADICTION_MODEL_CONTRACT_VERSION.into(), + output_profile: PREDICTION_CONTRADICTION_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-prediction-contradiction", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn assignment( + assignment_id: &str, + predicted: TemporalInterval, + observed: TemporalInterval, + stamp: &str, +) -> PredictionContradictionAssignment { + PredictionContradictionAssignment::new(assignment_id, predicted, observed, available(stamp)) + .expect("assignment") +} + +fn mixed_assignments() -> Vec { + vec![ + assignment( + "covered-a", + closed(0, 8), + closed(0, 10), + "2026-07-01T00:00:00Z", + ), + assignment( + "partial-b", + closed(0, 10), + closed(5, 15), + "2026-07-02T00:00:00Z", + ), + assignment( + "adjacent-c", + closed(0, 10), + closed(10, 20), + "2026-07-03T00:00:00Z", + ), + assignment( + "contradictory-d", + closed(0, 10), + closed(20, 30), + "2026-07-04T00:00:00Z", + ), + assignment( + "covered-e", + closed(0, 10), + closed(0, 10), + "2026-07-05T00:00:00Z", + ), + ] +} + +fn execute( + request: &AnalysisRunRequest, + assignments: &[PredictionContradictionAssignment], +) -> Result { + execute_prediction_contradiction_run( + request, + &accepted(request), + "snapshot-prediction-contradiction", + cutoff(), + assignments, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn mixed_kinds_emit_digest_bound_refusals_without_agreement_metric() { + let request = request(); + let execution = execute(&request, &mixed_assignments()).expect("execution"); + assert_eq!( + execution.artifact.schema_version, + PREDICTION_CONTRADICTION_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.assignment_count, 5); + assert_eq!(execution.artifact.covered_count, 2); + assert_eq!(execution.artifact.partial_overlap_count, 1); + assert_eq!(execution.artifact.adjacent_count, 1); + assert_eq!(execution.artifact.contradictory_count, 1); + assert_eq!(execution.artifact.refused_promotion_count, 3); + assert_eq!( + execution.artifact.inference_status, + "unmatched_prediction_is_not_observed" + ); + let payload = execution.artifact.to_json().expect("json"); + assert!(!payload.contains("contradiction_agreement_rate")); + assert!(!payload.contains("identity_recovery_rate")); + assert!(!payload.contains("scientific_acceptance")); + assert!(!payload.contains("edge_kind_recovery_rate")); + assert_eq!( + execution.terminal_result.run_state, + AnalysisRunTerminalState::Succeeded + ); + 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()) + ); + assert_eq!( + execution.terminal_result.result_schema_version.as_deref(), + Some(PREDICTION_CONTRADICTION_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[test] +fn compact_oversized_artifact_counts_fail_closed() { + let assignment_count = MAX_EVIDENCE_UNITS as u64 + 1; + let artifact = PredictionContradictionArtifact { + schema_version: PREDICTION_CONTRADICTION_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-compact-oversize".into(), + snapshot_id: "snapshot-compact-oversize".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + assignment_count, + covered_count: 1, + partial_overlap_count: 1, + adjacent_count: 1, + contradictory_count: assignment_count - 3, + refused_promotion_count: assignment_count - 1, + inference_status: "unmatched_prediction_is_not_observed".into(), + }; + let raw_payload = serde_json::to_string(&artifact).expect("raw json"); + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidPredictionContradictionArtifact) + ); + assert_eq!( + PredictionContradictionArtifact::from_json(&raw_payload), + Err(AnalysisEngineError::InvalidPredictionContradictionArtifact) + ); +} + +#[test] +fn future_available_assignments_are_excluded() { + let request = request(); + let mut with_future = mixed_assignments(); + with_future.push(assignment( + "future-covered", + closed(0, 8), + closed(0, 10), + "2026-08-02T00:00:00Z", + )); + let execution = execute(&request, &with_future).expect("cutoff"); + assert_eq!(execution.artifact.assignment_count, 5); + assert_eq!(execution.artifact.covered_count, 2); +} + +#[test] +fn future_duplicate_identity_cannot_change_a_historical_cutoff_result() { + let request = request(); + let mut with_future_duplicate = mixed_assignments(); + with_future_duplicate.push(assignment( + "covered-a", + closed(0, 10), + closed(20, 30), + "2026-08-02T00:00:00Z", + )); + + let execution = execute(&request, &with_future_duplicate) + .expect("future-unavailable evidence must not affect the historical run"); + assert_eq!(execution.artifact.assignment_count, 5); + assert_eq!(execution.artifact.covered_count, 2); + assert_eq!(execution.artifact.contradictory_count, 1); +} + +#[test] +fn empty_or_incomplete_kind_mix_and_duplicate_fail_closed() { + let request = request(); + let stamp = "2026-07-01T00:00:00Z"; + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let covered_only = vec![ + assignment("covered-a", closed(0, 8), closed(0, 10), stamp), + assignment("covered-b", closed(2, 8), closed(0, 10), stamp), + assignment("covered-c", closed(2, 10), closed(0, 10), stamp), + assignment("covered-d", closed(0, 10), closed(0, 10), stamp), + ]; + assert_eq!( + execute(&request, &covered_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let missing_contradiction = vec![ + assignment("covered-a", closed(0, 8), closed(0, 10), stamp), + assignment("partial-b", closed(0, 10), closed(5, 15), stamp), + assignment("adjacent-c", closed(0, 10), closed(10, 20), stamp), + ]; + assert_eq!( + execute(&request, &missing_contradiction), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + assignment("same", closed(0, 8), closed(0, 10), stamp), + assignment("same", closed(0, 10), closed(5, 15), stamp), + assignment("adjacent-c", closed(0, 10), closed(10, 20), stamp), + assignment("contradictory-d", closed(0, 10), closed(20, 30), stamp), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + PredictionContradictionAssignment::new("", closed(0, 8), closed(0, 10), available(stamp)), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_cutoff_mismatch_and_oversize() { + let request = request(); + let assignments = mixed_assignments(); + assert_eq!( + execute_prediction_contradiction_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + &assignments, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); + let mut mismatched = request.clone(); + mismatched.knowledge_cutoff = "2026-07-01T00:00:00Z".into(); + assert_eq!( + execute_prediction_contradiction_run( + &mismatched, + &accepted(&mismatched), + "snapshot-prediction-contradiction", + cutoff(), + &assignments, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + for profile in [ + "trsl_topic_lineage_v1", + "fitted_candidate_k_v1", + "pareto_candidate_k_v1", + "joint_posterior_draws_v1", + "method_effects_v1", + "copy_identity_v1", + "style_source_v1", + "prompt_source_v1", + "modality_source_v1", + "corpus_background_v1", + "citation_edge_v1", + "copied_text_v1", + "lineage_criterion_v1", + "composed_fitted_lineage_v1", + "case_deletion_refit_v1", + "topic_activity_v1", + "location_membership_v1", + "topic_context_posterior_v1", + "membership_posterior_icc_v1", + "membership_target_v1", + "outcome_order_v1", + "relation_absence_v1", + "subevent_containment_v1", + "episode_membership_v1", + "inferred_status_v1", + "role_contradiction_v1", + "retrospective_edge_v1", + "summarizes_edge_v1", + "support_edge_v1", + ] { + let mut reused = request.clone(); + reused.output_profile = profile.into(); + assert_eq!( + execute_prediction_contradiction_run( + &reused, + &accepted(&reused), + "snapshot-prediction-contradiction", + cutoff(), + &assignments, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } + let oversized: Vec = (0..=MAX_EVIDENCE_UNITS) + .map(|index| { + assignment( + &format!("assignment-{index}"), + closed(0, 8), + closed(0, 10), + "2026-07-01T00:00:00Z", + ) + }) + .collect(); + assert_eq!( + execute(&request, &oversized), + Err(AnalysisEngineError::LimitExceeded) + ); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..4489cee6f 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -85,6 +85,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | `tepp_api` router implemented-main plus future `interpretation_gateway` | partial | | CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | `compute_backend` CPU `f64` reference, bounded planning, and VRAM-budget refusal are active; full GPU streaming and CPU/GPU parity remain future | partial | | TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `prediction_contradiction` bounded Allen promotion gate on the active PR (`refuse_promotion` requires coverage; `refuse_contradiction_or_adjacency` is not promotion authority; remaining TDT/CHRONOS tasks stay accepted-target) | active-PR | +| prediction-contradiction analysis-run profile | ADR 0002/0016/0022/0079; unmatched predicted mass cannot become observed fact | `analysis_engine` `prediction_contradiction_v1` binds `refuse_promotion`; digest-bound refusals, not `contradiction_agreement_rate` inspect metric, not support-edge, not summarizes-edge, not retrospective-edge, not role-contradiction, not citation-edge, not copy-identity, not GPU, not MCMC, not topic birth/split/merge; not implemented-main | active-PR | | TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | `event_core` TDT tracking pair precision/recall and identity-switch rate 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` CHRONOS schema-slot precision/recall against known truth, `refuse_schema_prediction_as_instance` and `refuse_schema_prediction_as_transition`, label-target-derived calibrated occupancy RMSE `0.1410673598` versus always-fill `0.7071067812` in `schema_slot_contract.rs` on the active PR; remaining TDT detection/tracking, symbolic temporal consistency, 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` TDT story-segmentation `WindowDiff`/`Pk` on the active PR; remaining TDT/CHRONOS stack and any future `event_intelligence` crate remain accepted-target | active-PR | diff --git a/docs/adr/0079-prediction-contradiction-analysis-run.md b/docs/adr/0079-prediction-contradiction-analysis-run.md new file mode 100644 index 000000000..47c16da2c --- /dev/null +++ b/docs/adr/0079-prediction-contradiction-analysis-run.md @@ -0,0 +1,105 @@ +# ADR 0079 — Prediction-contradiction refusals as an analysis-run output profile + +**Decision status:** Accepted +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-09-03 +**Supersedes:** None; complements ADR 0002 (six-clock eligibility and Allen algebra), ADR 0016 (TDT/CHRONOS prediction stays hypothetical until coverage), and ADR 0022 (cutoff-safe analysis-run execution). Does not reuse ADR 0078 (support-edge), ADR 0077 (summarizes-edge), ADR 0076 (retrospective-edge), ADR 0075 (role-contradiction), ADR 0074 (subevent-containment), ADR 0073 (inferred-status), ADR 0072 (episode-membership), ADR 0071 (relation-absence), ADR 0070 (outcome-order), ADR 0069 (membership-target), ADR 0066 (location-membership), ADR 0065 (copied-text residue), ADR 0064 (provenance-is-not-transition / citation-edge), or ADR 0058 (copy-identity / template-copy). +**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 refuses to promote unmatched predicted mass to observed +fact, via `prediction_contradiction::refuse_promotion`. Coverage requires Allen +`during`, `starts`, `finishes`, or `equals`. Partial overlap, `meets` / +`met_by`, and Allen `before` / `after` stay hypothetical. +`refuse_contradiction_or_adjacency` is not promotion authority. +Operators still cannot request that mixed-kind census as a digest-bound +analysis-run output. + +Support-edge (#485 / ADR 0078) binds evidential kinds that never become +transitions. Summarizes-edge (#484 / ADR 0077) binds summary-versus-source +identity. Retrospective-edge (#483 / ADR 0076) binds later reports about +earlier events. Role-contradiction (#482 / ADR 0075) binds customer/competitor +group overlap. Citation-edge (#426 / ADR 0064) binds provenance-is-not-transition. + +`contradiction_agreement_rate` stays library-side. This slice does not put a +`scientific_acceptance` metric on inspect payloads. + +GPU kernels, MCMC, and topic birth/split/merge remain later GAP-004 work +and are not this slice. + +## Decision + +Add the `prediction_contradiction_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-validated `PredictionContradictionAssignment` rows with + predicted and observed closed event-time intervals and availability time; +- requires the request snapshot and knowledge cutoff to match the offered + input construction, comparing cutoffs as typed `KnowledgeCutoff::instant()` + values rather than strings; +- excludes assignments whose availability is later than the knowledge cutoff + before duplicate-identity checks; +- invokes `refuse_promotion` without reimplementing Allen classification; +- requires a mixed census of covered, partial-overlap, adjacent, and + contradictory pairs after cutoff exclusion, with matching promotion-refusal + counts; +- emits a canonical SHA-256-digested `tepp.prediction_contradiction.v1` + artifact with per-kind counts, matching refusal counts, and inference status + `unmatched_prediction_is_not_observed`; +- applies `MAX_EVIDENCE_UNITS` to both execution admission and artifact + validation; +- does not emit `contradiction_agreement_rate`, invent MCMC, select GPU + backends, or emit topic birth/split/merge events. + +## Alternatives considered + +1. Duplicate support-edge (#485 / ADR 0078) — rejected because that profile + binds evidential-versus-transition kinds, not predicted-versus-observed + promotion. +2. Duplicate citation-edge (#426 / ADR 0064) — rejected because that profile + binds provenance identity, not promotion coverage. +3. Duplicate role-contradiction (#482 / ADR 0075) — rejected because that + profile binds customer/competitor overlap, not predicted intervals. +4. Bind `refuse_contradiction_or_adjacency` as promotion authority — rejected + because partial overlap still leaves unmatched predicted mass. +5. Put `contradiction_agreement_rate` on the operator artifact — rejected + because inspect payloads stay metric-free and `tepp.scientific_acceptance.v1` + never appears. +6. Bind the existing prediction-contradiction refusals to ADR 0022's + analysis-run profile — accepted. + +## Consequences + +Operators can request cutoff-safe prediction-contradiction refusals as a +digest-bound terminal result. The artifact does not claim MCMC, GPU +parity, support-edge, summarizes-edge, retrospective-edge, role-contradiction, +subevent-containment, inferred-status, episode-membership, relation-absence, +outcome-order, membership-target, location-membership, membership-posterior +ICC, copied-text, copy-identity, citation-edge, corpus-background, +method-effect estimation, or topic birth/split/merge. Snapshot / profile / +cutoff mismatch, empty or incomplete mixed-kind corpora, duplicate assignment +identities, and oversized corpora fail closed. + +## Verification + +The PR includes Rust unit and integration tests for mixed four-kind +corpora, cutoff exclusion, equivalent RFC 3339 cutoff instants, +empty/incomplete/duplicate refusal, snapshot / profile / cutoff +mismatch, oversize, compact census claims above `MAX_EVIDENCE_UNITS`, +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 `prediction_contradiction_v1` profile. No persisted +schema migration is introduced. Supersede only with an ADR that keeps +unmatched predicted mass hypothetical and keeps +`contradiction_agreement_rate` off inspect payloads. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..227829b9c 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. | +| [0079](0079-prediction-contradiction-analysis-run.md) | Prediction-contradiction refusals as an analysis-run profile | Accepted | active-PR | Complements ADR 0002/0016/0022; `refuse_promotion` coverage gate, not support-edge, not citation-edge. | | [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. +- **prediction-contradiction analysis-run profile:** ADR 0079. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/prediction-contradiction-analysis-run.md b/docs/doctoring/prediction-contradiction-analysis-run.md new file mode 100644 index 000000000..572bf9622 --- /dev/null +++ b/docs/doctoring/prediction-contradiction-analysis-run.md @@ -0,0 +1,18 @@ +# Prediction-contradiction analysis-run composition + +**Active slice:** ADR 0079 / `prediction_contradiction_v1` +**Protected-main status:** not implemented-main + +`prediction_contradiction` already refuses to promote unmatched predicted +mass to observed fact. This slice binds predicted/observed interval pairs +and `refuse_promotion` to a cutoff-safe analysis-run profile so operators +can request a digest-bound identity artifact. + +The artifact inference status is `unmatched_prediction_is_not_observed`. +`contradiction_agreement_rate` stays library-side. This is not +support-edge, not summarizes-edge, not retrospective-edge, not +role-contradiction, not subevent-containment, not inferred-status, not +episode-membership, not relation-absence, not outcome-order, not +membership-target, not location-membership, not membership-posterior ICC, +not copied-text, not copy-identity, not citation-edge, not GPU, not MCMC, +and not topic birth/split/merge. From a2892b6ad4f632882f63da39eed2d4706ddf9213 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 10:05:20 +0900 Subject: [PATCH 2/5] test(analysis): require sparse prediction census evidence --- ...on_contradiction_sparse_census_contract.rs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 crates/analysis_engine/tests/prediction_contradiction_sparse_census_contract.rs diff --git a/crates/analysis_engine/tests/prediction_contradiction_sparse_census_contract.rs b/crates/analysis_engine/tests/prediction_contradiction_sparse_census_contract.rs new file mode 100644 index 000000000..829f4a024 --- /dev/null +++ b/crates/analysis_engine/tests/prediction_contradiction_sparse_census_contract.rs @@ -0,0 +1,112 @@ +//! Regression contract: observed relation classes are data, not run prerequisites. +//! +//! A real prediction-versus-observation census can legitimately contain only +//! covered pairs or only refusals. Zero counts for absent Allen support classes +//! must remain observable evidence rather than making the whole run invalid. + +use analysis_engine::{ + AnalysisEngineError, PREDICTION_CONTRADICTION_MODEL_CONTRACT_VERSION, + PREDICTION_CONTRADICTION_OUTPUT_PROFILE, PredictionContradictionAssignment, + execute_prediction_contradiction_run, +}; +use temporal_core::{ + AvailableTime, EventTime, KnowledgeCutoff, TemporalBoundary, TemporalInterval, + TemporalPrecision, +}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn available() -> AvailableTime { + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available") +} + +fn event_at(second: u8) -> EventTime { + EventTime::parse_rfc3339(&format!("2026-01-01T00:00:{second:02}Z")).expect("event") +} + +fn closed(start: u8, end: u8) -> TemporalInterval { + TemporalInterval::bounded( + TemporalBoundary::Included(event_at(start)), + TemporalBoundary::Included(event_at(end)), + TemporalPrecision::Second, + ) + .expect("closed interval") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "prediction-contradiction-sparse-census".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-prediction-contradiction".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: PREDICTION_CONTRADICTION_MODEL_CONTRACT_VERSION.into(), + output_profile: PREDICTION_CONTRADICTION_OUTPUT_PROFILE.into(), + } +} + +fn execute( + assignments: &[PredictionContradictionAssignment], +) -> Result { + let request = request(); + let accepted = AnalysisRunAccepted::new( + "run-prediction-contradiction-sparse", + "accepted", + &request.idempotency_key, + ) + .expect("accepted"); + execute_prediction_contradiction_run( + &request, + &accepted, + "snapshot-prediction-contradiction", + cutoff(), + assignments, + "2026-08-02T00:00:00Z", + ) +} + +fn assignment( + assignment_id: &str, + predicted: TemporalInterval, + observed: TemporalInterval, +) -> PredictionContradictionAssignment { + PredictionContradictionAssignment::new(assignment_id, predicted, observed, available()) + .expect("assignment") +} + +#[test] +fn covered_only_census_reports_zero_refusals_instead_of_rejecting_the_run() { + let assignments = vec![ + assignment("covered-a", closed(0, 8), closed(0, 10)), + assignment("covered-b", closed(2, 8), closed(0, 10)), + ]; + + let execution = execute(&assignments) + .expect("absence of contradiction classes is valid observed evidence"); + assert_eq!(execution.artifact.assignment_count, 2); + assert_eq!(execution.artifact.covered_count, 2); + assert_eq!(execution.artifact.partial_overlap_count, 0); + assert_eq!(execution.artifact.adjacent_count, 0); + assert_eq!(execution.artifact.contradictory_count, 0); + assert_eq!(execution.artifact.refused_promotion_count, 0); +} + +#[test] +fn contradiction_only_census_reports_zero_coverage_instead_of_rejecting_the_run() { + let assignments = vec![ + assignment("contradictory-a", closed(0, 10), closed(20, 30)), + assignment("contradictory-b", closed(20, 30), closed(0, 10)), + ]; + + let execution = execute(&assignments) + .expect("absence of covered pairs is valid observed evidence"); + assert_eq!(execution.artifact.assignment_count, 2); + assert_eq!(execution.artifact.covered_count, 0); + assert_eq!(execution.artifact.partial_overlap_count, 0); + assert_eq!(execution.artifact.adjacent_count, 0); + assert_eq!(execution.artifact.contradictory_count, 2); + assert_eq!(execution.artifact.refused_promotion_count, 2); +} From a64020152300232cb3214a66b45d97225b6d2b5b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 10:08:34 +0900 Subject: [PATCH 3/5] fix(analysis): preserve sparse prediction census evidence --- .../src/prediction_contradiction_artifact.rs | 42 ++++--------------- 1 file changed, 8 insertions(+), 34 deletions(-) diff --git a/crates/analysis_engine/src/prediction_contradiction_artifact.rs b/crates/analysis_engine/src/prediction_contradiction_artifact.rs index a6d22268f..077915190 100644 --- a/crates/analysis_engine/src/prediction_contradiction_artifact.rs +++ b/crates/analysis_engine/src/prediction_contradiction_artifact.rs @@ -169,12 +169,8 @@ impl PredictionContradictionArtifact { || !valid_identifier(&self.run_id) || !valid_identifier(&self.snapshot_id) || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() - || self.assignment_count < 4 + || self.assignment_count == 0 || self.assignment_count > MAX_EVIDENCE_UNITS as u64 - || self.covered_count == 0 - || self.partial_overlap_count == 0 - || self.adjacent_count == 0 - || self.contradictory_count == 0 || kind_sum != Some(self.assignment_count) || refused_sum != Some(self.refused_promotion_count) || self.refused_promotion_count.checked_add(self.covered_count) @@ -207,9 +203,9 @@ pub struct PredictionContradictionExecution { /// /// # Errors /// -/// Returns a request/receipt/snapshot/cutoff/profile error, empty or -/// incomplete mixed-kind corpus, missing refusal, duplicate assignment -/// identity, oversized corpus, or invalid artifact error. +/// Returns a request/receipt/snapshot/cutoff/profile error, empty admitted +/// corpus, duplicate assignment identity, oversized corpus, or invalid +/// artifact error. pub fn execute_prediction_contradiction_run( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, @@ -315,11 +311,7 @@ fn census_admitted_assignments( .and_then(|sum| sum.checked_add(adjacent_count)) .and_then(|sum| sum.checked_add(contradictory_count)) .ok_or(AnalysisEngineError::ArithmeticOverflow)?; - if assignment_count < 4 - || covered_count == 0 - || partial_overlap_count == 0 - || adjacent_count == 0 - || contradictory_count == 0 + if assignment_count == 0 || refused_promotion_count .checked_add(covered_count) .ok_or(AnalysisEngineError::ArithmeticOverflow)? @@ -455,35 +447,17 @@ mod tests { }, { let mut value = artifact.clone(); - value.assignment_count = 3; - value - }, - { - let mut value = artifact.clone(); + value.assignment_count = 0; value.covered_count = 0; - value.assignment_count = 3; - value.refused_promotion_count = 3; - value - }, - { - let mut value = artifact.clone(); value.partial_overlap_count = 0; - value.assignment_count = 3; - value.refused_promotion_count = 2; - value - }, - { - let mut value = artifact.clone(); value.adjacent_count = 0; - value.assignment_count = 3; - value.refused_promotion_count = 2; + value.contradictory_count = 0; + value.refused_promotion_count = 0; value }, { let mut value = artifact.clone(); - value.contradictory_count = 0; value.assignment_count = 3; - value.refused_promotion_count = 2; value }, { From 6b0c8de64f41bc11f8bf908e0f9cbe854c1e213c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 10:09:37 +0900 Subject: [PATCH 4/5] test(analysis): align prediction census admission --- ...iction_contradiction_execution_contract.rs | 21 +------------------ 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/crates/analysis_engine/tests/prediction_contradiction_execution_contract.rs b/crates/analysis_engine/tests/prediction_contradiction_execution_contract.rs index 45345c48d..b7d734018 100644 --- a/crates/analysis_engine/tests/prediction_contradiction_execution_contract.rs +++ b/crates/analysis_engine/tests/prediction_contradiction_execution_contract.rs @@ -220,32 +220,13 @@ fn future_duplicate_identity_cannot_change_a_historical_cutoff_result() { } #[test] -fn empty_or_incomplete_kind_mix_and_duplicate_fail_closed() { +fn empty_and_duplicate_corpora_fail_closed() { let request = request(); let stamp = "2026-07-01T00:00:00Z"; assert_eq!( execute(&request, &[]), Err(AnalysisEngineError::InvalidEvidence) ); - let covered_only = vec![ - assignment("covered-a", closed(0, 8), closed(0, 10), stamp), - assignment("covered-b", closed(2, 8), closed(0, 10), stamp), - assignment("covered-c", closed(2, 10), closed(0, 10), stamp), - assignment("covered-d", closed(0, 10), closed(0, 10), stamp), - ]; - assert_eq!( - execute(&request, &covered_only), - Err(AnalysisEngineError::InvalidEvidence) - ); - let missing_contradiction = vec![ - assignment("covered-a", closed(0, 8), closed(0, 10), stamp), - assignment("partial-b", closed(0, 10), closed(5, 15), stamp), - assignment("adjacent-c", closed(0, 10), closed(10, 20), stamp), - ]; - assert_eq!( - execute(&request, &missing_contradiction), - Err(AnalysisEngineError::InvalidEvidence) - ); let duplicates = vec![ assignment("same", closed(0, 8), closed(0, 10), stamp), assignment("same", closed(0, 10), closed(5, 15), stamp), From e07b2ff9f78ef456ff911b8643710af20921fe54 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 3 Sep 2026 19:06:00 +0900 Subject: [PATCH 5/5] docs(adr): align prediction contradiction lineage with sparse census --- ...9-prediction-contradiction-analysis-run.md | 120 ++++++------------ 1 file changed, 37 insertions(+), 83 deletions(-) diff --git a/docs/adr/0079-prediction-contradiction-analysis-run.md b/docs/adr/0079-prediction-contradiction-analysis-run.md index 47c16da2c..d8dbf9850 100644 --- a/docs/adr/0079-prediction-contradiction-analysis-run.md +++ b/docs/adr/0079-prediction-contradiction-analysis-run.md @@ -1,105 +1,59 @@ # ADR 0079 — Prediction-contradiction refusals as an analysis-run output profile -**Decision status:** Accepted -**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Decision status:** Proposed + +**Implementation maturity:** fold-child — source/tests/doctoring live on Draft #487 and are not implemented-main; canonical landing authority remains #416 pending repository-wide ADR normalization under #437. + **Date:** 2026-09-03 -**Supersedes:** None; complements ADR 0002 (six-clock eligibility and Allen algebra), ADR 0016 (TDT/CHRONOS prediction stays hypothetical until coverage), and ADR 0022 (cutoff-safe analysis-run execution). Does not reuse ADR 0078 (support-edge), ADR 0077 (summarizes-edge), ADR 0076 (retrospective-edge), ADR 0075 (role-contradiction), ADR 0074 (subevent-containment), ADR 0073 (inferred-status), ADR 0072 (episode-membership), ADR 0071 (relation-absence), ADR 0070 (outcome-order), ADR 0069 (membership-target), ADR 0066 (location-membership), ADR 0065 (copied-text residue), ADR 0064 (provenance-is-not-transition / citation-edge), or ADR 0058 (copy-identity / template-copy). + +**Supersedes:** None. This is implementation lineage under ADR 0002 (six-clock eligibility and Allen algebra), ADR 0016 (TDT/CHRONOS prediction remains hypothetical until supported), and ADR 0022 (cutoff-safe analysis-run execution). It does not mint an independently accepted bounded context. + **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 refuses to promote unmatched predicted mass to observed -fact, via `prediction_contradiction::refuse_promotion`. Coverage requires Allen -`during`, `starts`, `finishes`, or `equals`. Partial overlap, `meets` / -`met_by`, and Allen `before` / `after` stay hypothetical. -`refuse_contradiction_or_adjacency` is not promotion authority. -Operators still cannot request that mixed-kind census as a digest-bound -analysis-run output. - -Support-edge (#485 / ADR 0078) binds evidential kinds that never become -transitions. Summarizes-edge (#484 / ADR 0077) binds summary-versus-source -identity. Retrospective-edge (#483 / ADR 0076) binds later reports about -earlier events. Role-contradiction (#482 / ADR 0075) binds customer/competitor -group overlap. Citation-edge (#426 / ADR 0064) binds provenance-is-not-transition. +Protected main already refuses to promote unmatched predicted mass to observed fact through `prediction_contradiction::refuse_promotion`. Coverage requires Allen `during`, `starts`, `finishes`, or `equals`; partial overlap, `meets` / `met_by`, and Allen `before` / `after` remain hypothetical or contradictory as defined by the owning domain crate. `refuse_contradiction_or_adjacency` is not promotion authority. -`contradiction_agreement_rate` stays library-side. This slice does not put a -`scientific_acceptance` metric on inspect payloads. +The Analysis Run application boundary needs a digest-bound, cutoff-safe profile for those existing domain refusals without duplicating Allen semantics. `contradiction_agreement_rate` stays library-side and is not projected into operator inspect payloads. GPU kernels, MCMC, and topic birth/split/merge remain outside this slice. -GPU kernels, MCMC, and topic birth/split/merge remain later GAP-004 work -and are not this slice. +An earlier branch version made a scientific admission error: it required a deliberately mixed four-class fixture to appear in production data. Covered, partial-overlap, adjacent, and contradictory classes are observations, not design strata. A valid historical census may contain only covered predictions, only contradictions, or any other nonempty combination. Zero counts for absent classes are evidence and must not become missing-data failures. ## Decision -Add the `prediction_contradiction_v1` analysis-run output profile to -`analysis_engine`. The executor: - -- consumes already-validated `PredictionContradictionAssignment` rows with - predicted and observed closed event-time intervals and availability time; -- requires the request snapshot and knowledge cutoff to match the offered - input construction, comparing cutoffs as typed `KnowledgeCutoff::instant()` - values rather than strings; -- excludes assignments whose availability is later than the knowledge cutoff - before duplicate-identity checks; -- invokes `refuse_promotion` without reimplementing Allen classification; -- requires a mixed census of covered, partial-overlap, adjacent, and - contradictory pairs after cutoff exclusion, with matching promotion-refusal - counts; -- emits a canonical SHA-256-digested `tepp.prediction_contradiction.v1` - artifact with per-kind counts, matching refusal counts, and inference status - `unmatched_prediction_is_not_observed`; -- applies `MAX_EVIDENCE_UNITS` to both execution admission and artifact - validation; -- does not emit `contradiction_agreement_rate`, invent MCMC, select GPU - backends, or emit topic birth/split/merge events. +Keep `prediction_contradiction_v1` / `tepp.prediction_contradiction.v1` as a Draft Validation / Analysis Run profile to be folded into #416. The profile: + +- consumes bounded `PredictionContradictionAssignment` rows with predicted and observed closed event-time intervals plus availability time; +- requires request snapshot, output profile, and knowledge cutoff to match the execution context, comparing cutoffs through `KnowledgeCutoff::instant()` rather than RFC 3339 string identity; +- excludes rows whose `AvailableTime` is later than the knowledge cutoff **before** duplicate-identity admission, so future-unavailable evidence cannot perturb an earlier historical result; +- invokes `prediction_contradiction::refuse_promotion` rather than reimplementing Allen classification; +- admits any nonempty cutoff-eligible census up to `MAX_EVIDENCE_UNITS`; no relation class has a required minimum count; +- preserves exact count invariants: the four class counts sum to `assignment_count`, and `refused_promotion_count + covered_count == assignment_count`; +- emits canonical SHA-256-digested artifact identity and fixed inference status `unmatched_prediction_is_not_observed`; +- keeps `contradiction_agreement_rate` library-side and keeps inspect payloads metric-free; +- remains a fold child until #416 or a verified successor inherits the unique source, tests, fixtures, contract, and doctoring and reacquires exact-head gates. ## Alternatives considered -1. Duplicate support-edge (#485 / ADR 0078) — rejected because that profile - binds evidential-versus-transition kinds, not predicted-versus-observed - promotion. -2. Duplicate citation-edge (#426 / ADR 0064) — rejected because that profile - binds provenance identity, not promotion coverage. -3. Duplicate role-contradiction (#482 / ADR 0075) — rejected because that - profile binds customer/competitor overlap, not predicted intervals. -4. Bind `refuse_contradiction_or_adjacency` as promotion authority — rejected - because partial overlap still leaves unmatched predicted mass. -5. Put `contradiction_agreement_rate` on the operator artifact — rejected - because inspect payloads stay metric-free and `tepp.scientific_acceptance.v1` - never appears. -6. Bind the existing prediction-contradiction refusals to ADR 0022's - analysis-run profile — accepted. +1. Treat all four Allen support classes as mandatory design strata — rejected. Relation classes are outcomes observed in the admitted census, and requiring every class would reject truthful sparse historical data. +2. Duplicate support-edge semantics — rejected because support-edge classifies evidence-versus-transition roles, not predicted-versus-observed promotion. +3. Bind `refuse_contradiction_or_adjacency` as promotion authority — rejected because partial overlap can still leave unmatched predicted mass. +4. Put `contradiction_agreement_rate` on the operator artifact — rejected because the inspect contract is metric-free and the agreement helper is not a generative known-truth recovery metric. +5. Create a new bounded-context authority for this profile — rejected. The profile composes existing Prediction Contradiction domain truth inside the existing Validation / Analysis Run application boundary. ## Consequences -Operators can request cutoff-safe prediction-contradiction refusals as a -digest-bound terminal result. The artifact does not claim MCMC, GPU -parity, support-edge, summarizes-edge, retrospective-edge, role-contradiction, -subevent-containment, inferred-status, episode-membership, relation-absence, -outcome-order, membership-target, location-membership, membership-posterior -ICC, copied-text, copy-identity, citation-edge, corpus-background, -method-effect estimation, or topic birth/split/merge. Snapshot / profile / -cutoff mismatch, empty or incomplete mixed-kind corpora, duplicate assignment -identities, and oversized corpora fail closed. - -## Verification - -The PR includes Rust unit and integration tests for mixed four-kind -corpora, cutoff exclusion, equivalent RFC 3339 cutoff instants, -empty/incomplete/duplicate refusal, snapshot / profile / cutoff -mismatch, oversize, compact census claims above `MAX_EVIDENCE_UNITS`, -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 -``` +Operators can eventually request cutoff-safe prediction-contradiction refusals as a digest-bound terminal result once the profile lands through #416. Sparse covered-only or contradiction-only censuses remain valid and expose zero counts for absent classes. Snapshot/profile/cutoff mismatch, no cutoff-eligible evidence, duplicate identities among evidence available at the cutoff, count-invariant violations, and oversized corpora fail closed. + +This profile does not claim MCMC, GPU parity, support-edge, summarizes-edge, retrospective-edge, role-contradiction, subevent-containment, inferred-status, episode-membership, relation-absence, outcome-order, membership-target, location-membership, copy-identity, citation-edge, method-effect estimation, or topic birth/split/merge authority. + +## Verification and traceability + +Scientific RED `a2892b6ad4f632882f63da39eed2d4706ddf9213` adds covered-only and contradiction-only production-shaped censuses. Causal repair `a64020152300232cb3214a66b45d97225b6d2b5b` removes the artificial minimum/four-class gate while preserving nonempty evidence, bounded counts, exact count sums, digest validation, cutoff-before-identity admission, and the fixed claim boundary. Commit `6b0c8de64f41bc11f8bf908e0f9cbe854c1e213c` removes predecessor tests that encoded the rejected mixed-fixture eligibility rule. + +The four-class fixture remains test coverage because it exercises every `PredictionContradictionError` mapping; it is not a scientific eligibility condition. The eventual #416 survivor must run the focused sparse-census, cutoff-semantics, and execution contracts plus full `analysis_engine` tests, Clippy, documentation validation, and all live required workflows on its own exact head. ## Rollback and supersession -Rollback removes the `prediction_contradiction_v1` profile. No persisted -schema migration is introduced. Supersede only with an ADR that keeps -unmatched predicted mass hypothetical and keeps -`contradiction_agreement_rate` off inspect payloads. +Until protected-main integration, rollback is simply removal of the fold-child profile; no persisted schema migration exists. Supersession must preserve leakage-safe cutoff admission, truthful zero relation-class counts, Prediction Contradiction owner semantics, and the metric-free inspect boundary. ADR identity/status normalization remains owned by #437.