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..077915190 --- /dev/null +++ b/crates/analysis_engine/src/prediction_contradiction_artifact.rs @@ -0,0 +1,478 @@ +//! 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 == 0 + || self.assignment_count > MAX_EVIDENCE_UNITS as u64 + || 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 admitted +/// corpus, 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 == 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 = 0; + value.covered_count = 0; + value.partial_overlap_count = 0; + value.adjacent_count = 0; + value.contradictory_count = 0; + value.refused_promotion_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.assignment_count = 3; + 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..b7d734018 --- /dev/null +++ b/crates/analysis_engine/tests/prediction_contradiction_execution_contract.rs @@ -0,0 +1,333 @@ +//! 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_and_duplicate_corpora_fail_closed() { + let request = request(); + let stamp = "2026-07-01T00:00:00Z"; + assert_eq!( + execute(&request, &[]), + 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/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); +} 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..d8dbf9850 --- /dev/null +++ b/docs/adr/0079-prediction-contradiction-analysis-run.md @@ -0,0 +1,59 @@ +# ADR 0079 — Prediction-contradiction refusals as an analysis-run output profile + +**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. 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 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. + +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. + +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 + +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. 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 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 + +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. 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.