diff --git a/.github/workflows/docs-quality.yml b/.github/workflows/docs-quality.yml index eae33b97b..37e448b8e 100644 --- a/.github/workflows/docs-quality.yml +++ b/.github/workflows/docs-quality.yml @@ -7,6 +7,7 @@ on: - "**/*.json" - ".github/workflows/**" - "scripts/validate_documentation.py" + - "tests/quality/test_validate_documentation.py" push: branches: - main @@ -15,6 +16,7 @@ on: - "**/*.json" - ".github/workflows/**" - "scripts/validate_documentation.py" + - "tests/quality/test_validate_documentation.py" workflow_dispatch: permissions: diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe514db6..b0669bc5a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -61,10 +61,10 @@ boundaries above remain the target modular MSA architecture. | `tepp_simulation` | known-truth temporal/event data generation | | `validation_core` | RMSE, bias, coverage, graph, and Monte Carlo metrics | | `tepp_api` | versioned DTO, schema, and export contracts | +| `prediction_contradiction` | Allen promotion gate: `before`/`after` stay contradictory; `meets`/`met_by` stay unsupported; coverage is required before unmatched predicted mass may be authorized for promotion | -No crate exposes placeholder production behavior in Task 1. This prevents an -empty façade from becoming a de facto public API before its invariants and tests -exist. +Foundation crates expose only tested contracts. Empty façades are not public +APIs. ## Immutable evidence boundary diff --git a/CHANGELOG.md b/CHANGELOG.md index c1cc6e879..b297989b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ### Added +- `prediction_contradiction` promotion gate: `temporal_core` Allen classification refuses `before`/`after` as contradiction and `meets`/`met_by` as unsupported adjacency; `refuse_promotion` and `require_observed_coverage` refuse partial overlap that leaves unmatched predicted mass; `refuse_contradiction_or_adjacency` is the weaker contradiction/adjacency filter only; evidence available after the knowledge cutoff is ineligible. Label agreement is not RMSE recovery (ADR 0002, ADR 0016). Canonical docs name the crate, not a pull-request number, as the landable authority; `scripts/validate_documentation.py` fail-closes on `landable coverage gate is PR #N` including drafts #93, #94, #97, #101, #102, #104, #108, #109, and #111. The hourly queue lock also fail-closes when those drafts are omitted from Keep-unmerged sentences or when naruon live HTTP is pointed away from PR #107. - `persistence_postgres` backup/restore integrity: restored snapshots stay unusable until tenant, canonical `SHA-256`, knowledge-cutoff eligibility, temporal window order, and append-only triggers revalidate; SQL probes raise `restore integrity failed` (ADR 0013). - `persistence_postgres` concurrent document-write stress: atomic revise `DO` block that requires exactly one open `system_to` close, SQLSTATE mapping onto `ConcurrentWriteConflict` / `DuplicateDocumentRecord`, and live multi-session insert/revise/append-only proofs. No new migration number. - `tepp_api` naruon HTTP interchange: versioned `https` POST contracts for analysis-run create and modular export authorization that refuse table-access URLs, review/Copilot credential headers, reserved standard-header redefinition, principal-only export idempotency keys, and lexical inference claims (ADR 0011). diff --git a/Cargo.lock b/Cargo.lock index 372a55f43..f35579c9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -847,6 +847,13 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prediction_contradiction" +version = "0.1.0" +dependencies = [ + "temporal_core", +] + [[package]] name = "proc-macro2" version = "1.0.107" diff --git a/Cargo.toml b/Cargo.toml index 925659406..671bff1c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/prediction_contradiction", ] default-members = [ "crates/evidence_core", @@ -23,6 +24,7 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/prediction_contradiction", ] [workspace.package] diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 230c5abed..d5ed0729a 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -56,4 +56,4 @@ The documentation graph is **design-sufficient** when a reviewer can reconstruct It is **protected-main-sufficient** only after the canonical documents are integrated on protected `main`, remain semantically current with live code, and their required exact-head documentation/security/review gates pass. An active documentation PR can therefore be design-sufficient while the protected branch remains documentation-insufficient. -At the time of this review, immutable evidence records/exact spans, the Rust workspace quality foundation, and typed six-clock values/uncertain intervals (PR #8) are implemented-main. PR #9 is the active-PR that replays Task 4 Allen interval algebra and bounded path-consistency reasoner work onto that protected-main temporal foundation. Superseded PRs #5 and #6 remain historical lineage only. Event ontology, PostgreSQL persistence, shared-latent topic estimation, GPU kernels, TDT/CHRONOS intelligence, longitudinal ESEM/DSEM, visual analytics, production HTTP services, and deployment assurance remain later accepted-target or deployment-owned work. +At the time of this review, immutable evidence records/exact spans, the Rust workspace quality foundation, typed six-clock values/uncertain intervals (PR #8), Allen interval algebra and bounded path-consistency (PR #9), event ontology/membership, and PostgreSQL persistence through restore-integrity probes are implemented-main. The active-PR coverage gate in `prediction_contradiction` requires observed Allen coverage (`during`, `starts`, `finishes`, or `equals`) before unmatched predicted mass may be authorized for promotion; `refuse_promotion` is that authority and is not a contradiction-only filter. Coverage may authorize promotion; it does not convert a forecast into observed fact. Drafts #93, #94, #97, #101, #102, #104, #108, #109, and #111 are superseded non-landable lineage. Remaining TDT/CHRONOS tasks, shared-latent topic estimation, GPU kernels, longitudinal ESEM/DSEM, visual analytics, production HTTP services, and deployment assurance stay accepted-target or deployment-owned. diff --git a/README.md b/README.md index ae74015d3..78d461db9 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ implemented in Rust. ## Current implementation state -This branch establishes the Task 1 Rust workspace and quality-gate foundation. -The ten bounded crates compile independently but intentionally expose no -placeholder production APIs. Domain behavior begins in Task 2 with immutable -evidence identifiers and source records. +This branch keeps the Rust workspace quality foundation and the bounded +foundation crates. Domain crates expose only tested contracts: immutable +evidence, six-clock temporal values, event mentions/instances, relations, +membership, persistence, splits, simulation, validation, API DTOs, and the +predicted-versus-observed promotion gate. ```text crates/evidence_core @@ -22,6 +23,7 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api +crates/prediction_contradiction ``` ## Local verification diff --git a/crates/prediction_contradiction/Cargo.toml b/crates/prediction_contradiction/Cargo.toml new file mode 100644 index 000000000..65619f32b --- /dev/null +++ b/crates/prediction_contradiction/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "prediction_contradiction" +description = "Predicted intervals stay hypothetical unless later evidence covers them." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +keywords.workspace = true +categories.workspace = true +publish = false + +[dependencies] +temporal_core = { path = "../temporal_core", version = "0.1.0" } + +[lints] +workspace = true diff --git a/crates/prediction_contradiction/src/error.rs b/crates/prediction_contradiction/src/error.rs new file mode 100644 index 000000000..98073675c --- /dev/null +++ b/crates/prediction_contradiction/src/error.rs @@ -0,0 +1,82 @@ +//! Fail-closed prediction-contradiction errors. + +use std::fmt; + +/// A fail-closed prediction-contradiction error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum PredictionContradictionError { + /// Predicted and observed event-time intervals are Allen `before` or `after`. + PredictionContradictsObservation, + /// Predicted and observed intervals are adjacent and do not overlap in their interiors. + PredictionLacksOverlappingSupport, + /// Observed evidence overlaps the prediction but does not cover it. + PredictionNotCoveredByObservation, + /// Observed evidence became available after the analysis knowledge cutoff. + EvidenceAfterCutoff, + /// An interval is not a closed proper Allen input. + InvalidIntervalPayload, + /// An agreement-rate comparison used empty or length-mismatched slices. + AgreementSliceMismatch, +} + +impl fmt::Display for PredictionContradictionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::PredictionContradictsObservation => { + "predicted interval contradicts observed evidence" + } + Self::PredictionLacksOverlappingSupport => { + "predicted interval is adjacent to observation without overlapping support" + } + Self::PredictionNotCoveredByObservation => { + "observed evidence does not cover the predicted interval" + } + Self::EvidenceAfterCutoff => { + "observed evidence is available after the knowledge cutoff" + } + Self::InvalidIntervalPayload => "invalid prediction-contradiction payload", + Self::AgreementSliceMismatch => "agreement slices are empty or length-mismatched", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for PredictionContradictionError {} + +#[cfg(test)] +mod tests { + use super::PredictionContradictionError; + + #[test] + fn error_messages_are_stable() { + for (error, message) in [ + ( + PredictionContradictionError::PredictionContradictsObservation, + "predicted interval contradicts observed evidence", + ), + ( + PredictionContradictionError::PredictionLacksOverlappingSupport, + "predicted interval is adjacent to observation without overlapping support", + ), + ( + PredictionContradictionError::PredictionNotCoveredByObservation, + "observed evidence does not cover the predicted interval", + ), + ( + PredictionContradictionError::EvidenceAfterCutoff, + "observed evidence is available after the knowledge cutoff", + ), + ( + PredictionContradictionError::InvalidIntervalPayload, + "invalid prediction-contradiction payload", + ), + ( + PredictionContradictionError::AgreementSliceMismatch, + "agreement slices are empty or length-mismatched", + ), + ] { + assert_eq!(error.to_string(), message); + } + } +} diff --git a/crates/prediction_contradiction/src/interval.rs b/crates/prediction_contradiction/src/interval.rs new file mode 100644 index 000000000..9087bc1a3 --- /dev/null +++ b/crates/prediction_contradiction/src/interval.rs @@ -0,0 +1,493 @@ +//! Predicted-versus-observed promotion using `temporal_core` Allen classification. + +use crate::PredictionContradictionError; +use temporal_core::{ + AllenRelation, AvailableTime, EventTime, KnowledgeCutoff, TemporalError, TemporalInterval, + classify_interval_relation, +}; + +fn map_temporal(error: TemporalError) -> PredictionContradictionError { + let _ = error; + PredictionContradictionError::InvalidIntervalPayload +} + +/// How later-observed evidence relates to a predicted event-time interval. +/// +/// `Ok(())` from [`refuse_promotion`] or [`require_observed_coverage`] means +/// every predicted instant has observed support. `Ok(())` from +/// [`refuse_contradiction_or_adjacency`] only means the pair is not an Allen +/// contradiction or adjacency refusal. Only +/// [`PromotionSupport::ObservedCoversPrediction`] authorizes promotion. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PromotionSupport { + /// Observed interval covers every instant of the predicted interval. + ObservedCoversPrediction, + /// Interiors overlap, but some predicted mass has no observed support. + PartialOverlap, + /// Intervals share an endpoint and have no interior overlap. + AdjacentWithoutOverlap, + /// Intervals are strictly disjoint with a gap. + ContradictoryDisjoint, +} + +/// Classify predicted-versus-observed support without applying cutoff policy. +/// +/// # Errors +/// +/// Returns [`PredictionContradictionError::InvalidIntervalPayload`] when either +/// interval is not a closed proper Allen input. +pub fn classify_promotion_support( + predicted: &TemporalInterval, + observed: &TemporalInterval, +) -> Result { + match classify_interval_relation(predicted, observed).map_err(map_temporal)? { + AllenRelation::Before | AllenRelation::After => Ok(PromotionSupport::ContradictoryDisjoint), + AllenRelation::Meets | AllenRelation::MetBy => Ok(PromotionSupport::AdjacentWithoutOverlap), + AllenRelation::Overlaps + | AllenRelation::OverlappedBy + | AllenRelation::Contains + | AllenRelation::StartedBy + | AllenRelation::FinishedBy => Ok(PromotionSupport::PartialOverlap), + AllenRelation::Starts + | AllenRelation::During + | AllenRelation::Finishes + | AllenRelation::Equals => Ok(PromotionSupport::ObservedCoversPrediction), + } +} + +/// Return whether two closed proper intervals are Allen `before` or `after`. +/// +/// Adjacent `meets` / `met_by` pairs are not contradictions. They share an +/// endpoint and remain consistent under Allen (1983); they still lack interior +/// overlap and therefore cannot support promotion. +/// +/// # Errors +/// +/// Returns [`PredictionContradictionError::InvalidIntervalPayload`] when either +/// interval is not a closed proper Allen input. +pub fn intervals_contradict( + predicted: &TemporalInterval, + observed: &TemporalInterval, +) -> Result { + match classify_interval_relation(predicted, observed).map_err(map_temporal)? { + AllenRelation::Before | AllenRelation::After => Ok(true), + AllenRelation::Meets + | AllenRelation::MetBy + | AllenRelation::Overlaps + | AllenRelation::OverlappedBy + | AllenRelation::Starts + | AllenRelation::StartedBy + | AllenRelation::During + | AllenRelation::Contains + | AllenRelation::Finishes + | AllenRelation::FinishedBy + | AllenRelation::Equals => Ok(false), + } +} + +/// Refuse only Allen contradiction or adjacency; this is not promotion authority. +/// +/// Success means the pair is not Allen `before` / `after` and is not merely +/// adjacent. Partial overlap still leaves unmatched predicted mass. Call +/// [`refuse_promotion`] or [`require_observed_coverage`] before authorizing +/// promotion of unmatched predicted mass. +/// +/// This function classifies intervals with +/// [`temporal_core::classify_interval_relation`]. It does not run the +/// path-consistency reasoner. +/// +/// # Errors +/// +/// Returns [`PredictionContradictionError::EvidenceAfterCutoff`] when +/// `observed_available` is later than `cutoff`. Returns +/// [`PredictionContradictionError::PredictionContradictsObservation`] for +/// Allen `before` / `after`. Returns +/// [`PredictionContradictionError::PredictionLacksOverlappingSupport`] for +/// `meets` / `met_by`. Returns +/// [`PredictionContradictionError::InvalidIntervalPayload`] when either +/// interval is not a closed proper Allen input. +pub fn refuse_contradiction_or_adjacency( + predicted: &TemporalInterval, + observed: &TemporalInterval, + observed_available: AvailableTime, + cutoff: KnowledgeCutoff, +) -> Result<(), PredictionContradictionError> { + if observed_available.instant() > cutoff.instant() { + return Err(PredictionContradictionError::EvidenceAfterCutoff); + } + match classify_interval_relation(predicted, observed).map_err(map_temporal)? { + AllenRelation::Before | AllenRelation::After => { + Err(PredictionContradictionError::PredictionContradictsObservation) + } + AllenRelation::Meets | AllenRelation::MetBy => { + Err(PredictionContradictionError::PredictionLacksOverlappingSupport) + } + AllenRelation::Overlaps + | AllenRelation::OverlappedBy + | AllenRelation::Starts + | AllenRelation::StartedBy + | AllenRelation::During + | AllenRelation::Contains + | AllenRelation::Finishes + | AllenRelation::FinishedBy + | AllenRelation::Equals => Ok(()), + } +} + +/// Refuse promotion unless later-observed evidence covers the prediction. +/// +/// This is the promotion-authority entry point. It is identical to +/// [`require_observed_coverage`]: unmatched predicted mass stays hypothetical. +/// +/// # Errors +/// +/// Returns the same errors as [`require_observed_coverage`]. +pub fn refuse_promotion( + predicted: &TemporalInterval, + observed: &TemporalInterval, + observed_available: AvailableTime, + cutoff: KnowledgeCutoff, +) -> Result<(), PredictionContradictionError> { + require_observed_coverage(predicted, observed, observed_available, cutoff) +} + +/// Refuse promotion unless later-observed evidence covers the prediction. +/// +/// Coverage requires Allen `during`, `starts`, `finishes`, or `equals`. +/// Partial overlap leaves unmatched predicted mass and stays hypothetical. +/// +/// # Errors +/// +/// Returns [`PredictionContradictionError::EvidenceAfterCutoff`] when +/// `observed_available` is later than `cutoff`. Returns +/// [`PredictionContradictionError::PredictionContradictsObservation`] for +/// Allen `before` / `after`. Returns +/// [`PredictionContradictionError::PredictionLacksOverlappingSupport`] for +/// `meets` / `met_by`. Returns +/// [`PredictionContradictionError::PredictionNotCoveredByObservation`] for +/// partial overlap. Returns +/// [`PredictionContradictionError::InvalidIntervalPayload`] when either +/// interval is not a closed proper Allen input. +pub fn require_observed_coverage( + predicted: &TemporalInterval, + observed: &TemporalInterval, + observed_available: AvailableTime, + cutoff: KnowledgeCutoff, +) -> Result<(), PredictionContradictionError> { + if observed_available.instant() > cutoff.instant() { + return Err(PredictionContradictionError::EvidenceAfterCutoff); + } + match classify_promotion_support(predicted, observed)? { + PromotionSupport::ObservedCoversPrediction => Ok(()), + PromotionSupport::PartialOverlap => { + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + } + PromotionSupport::AdjacentWithoutOverlap => { + Err(PredictionContradictionError::PredictionLacksOverlappingSupport) + } + PromotionSupport::ContradictoryDisjoint => { + Err(PredictionContradictionError::PredictionContradictsObservation) + } + } +} + +/// Fraction of contradiction flags that match independently supplied labels. +/// +/// This is a label-agreement helper for the promotion gate. It is not RMSE, +/// bias, or interval-coverage recovery against a generative truth process. +/// +/// # Errors +/// +/// Returns [`PredictionContradictionError::AgreementSliceMismatch`] when +/// either slice is empty or the lengths differ. +pub fn contradiction_agreement_rate( + truth: &[bool], + decided: &[bool], +) -> Result { + if truth.is_empty() || truth.len() != decided.len() { + return Err(PredictionContradictionError::AgreementSliceMismatch); + } + let mut matches = 0_u32; + for (truth_flag, decided_flag) in truth.iter().zip(decided) { + if truth_flag == decided_flag { + matches += 1; + } + } + #[allow(clippy::cast_precision_loss)] + let rate = f64::from(matches) / truth.len() as f64; + Ok(rate) +} + +#[cfg(test)] +mod tests { + use super::{ + PromotionSupport, classify_promotion_support, contradiction_agreement_rate, + intervals_contradict, refuse_contradiction_or_adjacency, refuse_promotion, + require_observed_coverage, + }; + use crate::PredictionContradictionError; + use temporal_core::{ + AvailableTime, EventTime, KnowledgeCutoff, TemporalBoundary, TemporalInterval, + TemporalPrecision, + }; + + 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 clocks() -> (AvailableTime, KnowledgeCutoff) { + ( + AvailableTime::parse_rfc3339("2026-01-02T00:00:00Z").expect("available"), + KnowledgeCutoff::parse_rfc3339("2026-01-03T00:00:00Z").expect("cutoff"), + ) + } + + #[test] + fn intervals_contradict_only_before_and_after() { + let predicted = closed(0, 10); + assert!(intervals_contradict(&predicted, &closed(20, 30)).expect("before")); + assert!(intervals_contradict(&closed(40, 50), &predicted).expect("after")); + assert!(!intervals_contradict(&predicted, &closed(10, 20)).expect("meets")); + assert!(!intervals_contradict(&closed(10, 20), &predicted).expect("met_by")); + assert!(!intervals_contradict(&predicted, &closed(5, 15)).expect("overlaps")); + assert!(!intervals_contradict(&closed(5, 15), &predicted).expect("overlapped_by")); + assert!(!intervals_contradict(&predicted, &closed(0, 8)).expect("started_by")); + assert!(!intervals_contradict(&closed(0, 8), &predicted).expect("starts")); + assert!(!intervals_contradict(&predicted, &closed(2, 8)).expect("contains")); + assert!(!intervals_contradict(&closed(2, 8), &predicted).expect("during")); + assert!(!intervals_contradict(&predicted, &closed(2, 10)).expect("finished_by")); + assert!(!intervals_contradict(&closed(2, 10), &predicted).expect("finishes")); + assert!(!intervals_contradict(&predicted, &closed(0, 10)).expect("equals")); + } + + #[test] + fn refuse_contradiction_or_adjacency_accepts_overlap_family_and_refuses_gaps() { + let (available, cutoff) = clocks(); + let predicted = closed(0, 10); + refuse_contradiction_or_adjacency(&predicted, &closed(5, 15), available, cutoff) + .expect("overlap"); + refuse_contradiction_or_adjacency(&closed(5, 15), &predicted, available, cutoff) + .expect("overlapped_by"); + refuse_contradiction_or_adjacency(&predicted, &closed(0, 8), available, cutoff) + .expect("started_by"); + refuse_contradiction_or_adjacency(&closed(0, 8), &predicted, available, cutoff) + .expect("starts"); + refuse_contradiction_or_adjacency(&predicted, &closed(2, 8), available, cutoff) + .expect("contains"); + refuse_contradiction_or_adjacency(&closed(2, 8), &predicted, available, cutoff) + .expect("during"); + refuse_contradiction_or_adjacency(&predicted, &closed(2, 10), available, cutoff) + .expect("finished_by"); + refuse_contradiction_or_adjacency(&closed(2, 10), &predicted, available, cutoff) + .expect("finishes"); + refuse_contradiction_or_adjacency(&predicted, &closed(0, 10), available, cutoff) + .expect("equals"); + assert_eq!( + refuse_contradiction_or_adjacency(&predicted, &closed(20, 30), available, cutoff), + Err(PredictionContradictionError::PredictionContradictsObservation) + ); + assert_eq!( + refuse_contradiction_or_adjacency(&closed(40, 50), &predicted, available, cutoff), + Err(PredictionContradictionError::PredictionContradictsObservation) + ); + assert_eq!( + refuse_contradiction_or_adjacency(&predicted, &closed(10, 20), available, cutoff), + Err(PredictionContradictionError::PredictionLacksOverlappingSupport) + ); + assert_eq!( + refuse_contradiction_or_adjacency(&closed(10, 20), &predicted, available, cutoff), + Err(PredictionContradictionError::PredictionLacksOverlappingSupport) + ); + } + + #[test] + fn classify_promotion_support_labels_all_thirteen_relations() { + let predicted = closed(0, 10); + assert_eq!( + classify_promotion_support(&predicted, &closed(20, 30)).expect("before"), + PromotionSupport::ContradictoryDisjoint + ); + assert_eq!( + classify_promotion_support(&closed(40, 50), &predicted).expect("after"), + PromotionSupport::ContradictoryDisjoint + ); + assert_eq!( + classify_promotion_support(&predicted, &closed(10, 20)).expect("meets"), + PromotionSupport::AdjacentWithoutOverlap + ); + assert_eq!( + classify_promotion_support(&closed(10, 20), &predicted).expect("met_by"), + PromotionSupport::AdjacentWithoutOverlap + ); + assert_eq!( + classify_promotion_support(&predicted, &closed(5, 15)).expect("overlaps"), + PromotionSupport::PartialOverlap + ); + assert_eq!( + classify_promotion_support(&closed(5, 15), &predicted).expect("overlapped_by"), + PromotionSupport::PartialOverlap + ); + assert_eq!( + classify_promotion_support(&predicted, &closed(0, 8)).expect("started_by"), + PromotionSupport::PartialOverlap + ); + assert_eq!( + classify_promotion_support(&predicted, &closed(2, 8)).expect("contains"), + PromotionSupport::PartialOverlap + ); + assert_eq!( + classify_promotion_support(&predicted, &closed(2, 10)).expect("finished_by"), + PromotionSupport::PartialOverlap + ); + assert_eq!( + classify_promotion_support(&closed(0, 8), &predicted).expect("starts"), + PromotionSupport::ObservedCoversPrediction + ); + assert_eq!( + classify_promotion_support(&closed(2, 8), &predicted).expect("during"), + PromotionSupport::ObservedCoversPrediction + ); + assert_eq!( + classify_promotion_support(&closed(2, 10), &predicted).expect("finishes"), + PromotionSupport::ObservedCoversPrediction + ); + assert_eq!( + classify_promotion_support(&predicted, &closed(0, 10)).expect("equals"), + PromotionSupport::ObservedCoversPrediction + ); + } + + #[test] + fn refuse_promotion_matches_require_observed_coverage() { + let (available, cutoff) = clocks(); + let predicted = closed(0, 10); + refuse_promotion(&closed(0, 8), &predicted, available, cutoff).expect("starts"); + refuse_promotion(&predicted, &closed(0, 10), available, cutoff).expect("equals"); + assert_eq!( + refuse_promotion(&predicted, &closed(5, 15), available, cutoff), + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + ); + assert_eq!( + refuse_promotion(&predicted, &closed(20, 30), available, cutoff), + Err(PredictionContradictionError::PredictionContradictsObservation) + ); + assert_eq!( + refuse_promotion(&predicted, &closed(10, 20), available, cutoff), + Err(PredictionContradictionError::PredictionLacksOverlappingSupport) + ); + } + + #[test] + fn require_observed_coverage_accepts_only_full_coverage() { + let (available, cutoff) = clocks(); + let predicted = closed(0, 10); + require_observed_coverage(&closed(0, 8), &predicted, available, cutoff).expect("starts"); + require_observed_coverage(&closed(2, 8), &predicted, available, cutoff).expect("during"); + require_observed_coverage(&closed(2, 10), &predicted, available, cutoff).expect("finishes"); + require_observed_coverage(&predicted, &closed(0, 10), available, cutoff).expect("equals"); + assert_eq!( + require_observed_coverage(&predicted, &closed(5, 15), available, cutoff), + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + ); + assert_eq!( + require_observed_coverage(&closed(5, 15), &predicted, available, cutoff), + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + ); + assert_eq!( + require_observed_coverage(&predicted, &closed(0, 8), available, cutoff), + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + ); + assert_eq!( + require_observed_coverage(&predicted, &closed(2, 8), available, cutoff), + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + ); + assert_eq!( + require_observed_coverage(&predicted, &closed(2, 10), available, cutoff), + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + ); + assert_eq!( + require_observed_coverage(&predicted, &closed(20, 30), available, cutoff), + Err(PredictionContradictionError::PredictionContradictsObservation) + ); + assert_eq!( + require_observed_coverage(&predicted, &closed(10, 20), available, cutoff), + Err(PredictionContradictionError::PredictionLacksOverlappingSupport) + ); + } + + #[test] + fn cutoff_and_half_open_payloads_fail_closed() { + let (available, cutoff) = clocks(); + let predicted = closed(0, 10); + let on_cutoff = ( + AvailableTime::parse_rfc3339("2026-01-03T00:00:00Z").expect("on cutoff"), + KnowledgeCutoff::parse_rfc3339("2026-01-03T00:00:00Z").expect("cutoff"), + ); + require_observed_coverage(&predicted, &closed(0, 10), on_cutoff.0, on_cutoff.1) + .expect("available == cutoff"); + refuse_promotion(&predicted, &closed(0, 10), on_cutoff.0, on_cutoff.1) + .expect("available == cutoff on promotion"); + let late = AvailableTime::parse_rfc3339("2026-01-04T00:00:00Z").expect("late"); + assert_eq!( + refuse_contradiction_or_adjacency(&predicted, &closed(5, 15), late, cutoff), + Err(PredictionContradictionError::EvidenceAfterCutoff) + ); + assert_eq!( + refuse_promotion(&predicted, &closed(5, 15), late, cutoff), + Err(PredictionContradictionError::EvidenceAfterCutoff) + ); + assert_eq!( + require_observed_coverage(&predicted, &closed(0, 10), late, cutoff), + Err(PredictionContradictionError::EvidenceAfterCutoff) + ); + let half_open = TemporalInterval::bounded( + TemporalBoundary::Included(event_at(0)), + TemporalBoundary::Excluded(event_at(10)), + TemporalPrecision::Second, + ) + .expect("half-open"); + assert_eq!( + intervals_contradict(&half_open, &closed(20, 30)), + Err(PredictionContradictionError::InvalidIntervalPayload) + ); + assert_eq!( + refuse_contradiction_or_adjacency(&half_open, &closed(20, 30), available, cutoff), + Err(PredictionContradictionError::InvalidIntervalPayload) + ); + assert_eq!( + refuse_promotion(&half_open, &closed(20, 30), available, cutoff), + Err(PredictionContradictionError::InvalidIntervalPayload) + ); + assert_eq!( + classify_promotion_support(&predicted, &half_open), + Err(PredictionContradictionError::InvalidIntervalPayload) + ); + assert_eq!( + require_observed_coverage(&predicted, &half_open, available, cutoff), + Err(PredictionContradictionError::InvalidIntervalPayload) + ); + } + + #[test] + fn agreement_rate_matches_or_fails_closed() { + let matched = contradiction_agreement_rate(&[true], &[true]).expect("rate"); + assert!((matched - 1.0).abs() < f64::EPSILON); + assert_eq!( + contradiction_agreement_rate(&[], &[]), + Err(PredictionContradictionError::AgreementSliceMismatch) + ); + assert_eq!( + contradiction_agreement_rate(&[true], &[]), + Err(PredictionContradictionError::AgreementSliceMismatch) + ); + } +} diff --git a/crates/prediction_contradiction/src/lib.rs b/crates/prediction_contradiction/src/lib.rs new file mode 100644 index 000000000..0ebd8aac7 --- /dev/null +++ b/crates/prediction_contradiction/src/lib.rs @@ -0,0 +1,33 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +//! Predicted intervals that contradict observations stay hypothetical. +//! +//! A forecast cannot be promoted to an observed event when +//! [`temporal_core::classify_interval_relation`] returns Allen `before` or +//! `after`, or when the pair only `meets` / is `met_by`. Partial overlap is +//! not a contradiction, but it also does not cover unmatched predicted +//! mass. [`refuse_promotion`] and [`require_observed_coverage`] are the +//! promotion-authority gates. [`refuse_contradiction_or_adjacency`] only +//! answers contradiction or adjacency. Evidence whose availability time +//! exceeds the analysis knowledge cutoff is ineligible (ADR 0002, ADR 0016). +//! This crate does not run the path-consistency reasoner. + +mod error; +mod interval; + +/// Fail-closed prediction-contradiction errors. +pub use error::PredictionContradictionError; +/// How later-observed evidence relates to a predicted event-time interval. +pub use interval::PromotionSupport; +/// Classify predicted-versus-observed support without applying cutoff policy. +pub use interval::classify_promotion_support; +/// Fraction of contradiction flags that match independently supplied labels. +pub use interval::contradiction_agreement_rate; +/// Return whether two closed proper intervals are Allen `before` or `after`. +pub use interval::intervals_contradict; +/// Refuse only Allen contradiction or adjacency; this is not promotion authority. +pub use interval::refuse_contradiction_or_adjacency; +/// Refuse promotion unless later-observed evidence covers the prediction. +pub use interval::refuse_promotion; +/// Refuse promotion unless later-observed evidence covers the prediction. +pub use interval::require_observed_coverage; diff --git a/crates/prediction_contradiction/tests/contradiction_contract.rs b/crates/prediction_contradiction/tests/contradiction_contract.rs new file mode 100644 index 000000000..a523a80b6 --- /dev/null +++ b/crates/prediction_contradiction/tests/contradiction_contract.rs @@ -0,0 +1,453 @@ +//! Predicted intervals stay hypothetical unless later-available evidence covers +//! every predicted instant (Allen during, starts, finishes, or equals). + +use prediction_contradiction::{ + PredictionContradictionError, PromotionSupport, classify_promotion_support, + contradiction_agreement_rate, intervals_contradict, refuse_contradiction_or_adjacency, + refuse_promotion, require_observed_coverage, +}; +use temporal_core::{ + AvailableTime, EventTime, KnowledgeCutoff, TemporalBoundary, TemporalInterval, + TemporalPrecision, +}; + +fn event_at(second: u8) -> EventTime { + EventTime::parse_rfc3339(&format!("2026-01-01T00:00:{second:02}Z")).expect("event time") +} + +fn closed_event_interval(start: u8, end: u8) -> TemporalInterval { + TemporalInterval::bounded( + TemporalBoundary::Included(event_at(start)), + TemporalBoundary::Included(event_at(end)), + TemporalPrecision::Second, + ) + .expect("closed proper interval") +} + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available time") +} + +fn cutoff(stamp: &str) -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339(stamp).expect("knowledge cutoff") +} + +fn eligible_clocks() -> (AvailableTime, KnowledgeCutoff) { + ( + available("2026-01-02T00:00:00Z"), + cutoff("2026-01-03T00:00:00Z"), + ) +} + +#[test] +fn before_and_after_cannot_become_observed_fact() { + let predicted = closed_event_interval(0, 10); + let later_observed = closed_event_interval(20, 30); + let earlier_observed = closed_event_interval(40, 50); + let predicted_later = closed_event_interval(0, 10); + let (observed_available, knowledge_cutoff) = eligible_clocks(); + + assert!(intervals_contradict(&predicted, &later_observed).expect("before")); + assert_eq!( + refuse_contradiction_or_adjacency( + &predicted, + &later_observed, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionContradictsObservation) + ); + assert_eq!( + refuse_promotion( + &predicted, + &later_observed, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionContradictsObservation) + ); + + assert!(intervals_contradict(&earlier_observed, &predicted_later).expect("after")); + assert_eq!( + refuse_contradiction_or_adjacency( + &earlier_observed, + &predicted_later, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionContradictsObservation) + ); + assert_eq!( + refuse_promotion( + &earlier_observed, + &predicted_later, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionContradictsObservation) + ); +} + +#[test] +fn meeting_intervals_are_adjacent_not_allen_contradiction() { + let predicted = closed_event_interval(0, 10); + let meeting = closed_event_interval(10, 20); + let met_by = closed_event_interval(10, 20); + let earlier = closed_event_interval(0, 10); + let (observed_available, knowledge_cutoff) = eligible_clocks(); + + assert!(!intervals_contradict(&predicted, &meeting).expect("meets")); + assert_eq!( + refuse_contradiction_or_adjacency( + &predicted, + &meeting, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionLacksOverlappingSupport) + ); + assert_eq!( + refuse_promotion(&predicted, &meeting, observed_available, knowledge_cutoff), + Err(PredictionContradictionError::PredictionLacksOverlappingSupport) + ); + assert!(!intervals_contradict(&met_by, &earlier).expect("met_by")); + assert_eq!( + refuse_contradiction_or_adjacency(&met_by, &earlier, observed_available, knowledge_cutoff), + Err(PredictionContradictionError::PredictionLacksOverlappingSupport) + ); + assert_eq!( + refuse_promotion(&met_by, &earlier, observed_available, knowledge_cutoff), + Err(PredictionContradictionError::PredictionLacksOverlappingSupport) + ); +} + +#[test] +fn overlapping_observation_is_not_contradiction_and_is_not_coverage() { + let predicted = closed_event_interval(0, 10); + let overlapping = closed_event_interval(5, 15); + let (observed_available, knowledge_cutoff) = eligible_clocks(); + assert!(!intervals_contradict(&predicted, &overlapping).expect("overlaps")); + refuse_contradiction_or_adjacency( + &predicted, + &overlapping, + observed_available, + knowledge_cutoff, + ) + .expect("overlap is not Allen contradiction"); + assert_eq!( + classify_promotion_support(&predicted, &overlapping).expect("overlaps"), + PromotionSupport::PartialOverlap + ); + assert_eq!( + require_observed_coverage( + &predicted, + &overlapping, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + ); +} + +#[test] +fn refuse_promotion_refuses_unmatched_predicted_mass() { + let predicted = closed_event_interval(0, 10); + let overlapping = closed_event_interval(5, 15); + let contained = closed_event_interval(2, 8); + let (observed_available, knowledge_cutoff) = eligible_clocks(); + assert_eq!( + refuse_promotion( + &predicted, + &overlapping, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + ); + assert_eq!( + refuse_promotion( + &predicted, + &contained, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + ); +} + +#[test] +fn evidence_available_after_cutoff_is_ineligible() { + let predicted = closed_event_interval(0, 10); + let overlapping = closed_event_interval(5, 15); + assert_eq!( + refuse_contradiction_or_adjacency( + &predicted, + &overlapping, + available("2026-01-04T00:00:00Z"), + cutoff("2026-01-03T00:00:00Z"), + ), + Err(PredictionContradictionError::EvidenceAfterCutoff) + ); + assert_eq!( + refuse_promotion( + &predicted, + &overlapping, + available("2026-01-04T00:00:00Z"), + cutoff("2026-01-03T00:00:00Z"), + ), + Err(PredictionContradictionError::EvidenceAfterCutoff) + ); +} + +#[test] +fn half_open_intervals_are_not_allen_inputs() { + let predicted = TemporalInterval::bounded( + TemporalBoundary::Included(event_at(0)), + TemporalBoundary::Excluded(event_at(10)), + TemporalPrecision::Second, + ) + .expect("half-open interval is representable"); + let observed = closed_event_interval(20, 30); + assert_eq!( + intervals_contradict(&predicted, &observed), + Err(PredictionContradictionError::InvalidIntervalPayload) + ); +} + +#[test] +fn agreement_rate_matches_known_allen_labels_not_promote_all() { + let pairs = [ + (closed_event_interval(0, 10), closed_event_interval(20, 30)), + (closed_event_interval(0, 10), closed_event_interval(5, 15)), + (closed_event_interval(0, 10), closed_event_interval(10, 20)), + (closed_event_interval(40, 50), closed_event_interval(0, 10)), + ]; + let truth = [true, false, false, true]; + let decided = [ + intervals_contradict(&pairs[0].0, &pairs[0].1).expect("before"), + intervals_contradict(&pairs[1].0, &pairs[1].1).expect("overlaps"), + intervals_contradict(&pairs[2].0, &pairs[2].1).expect("meets"), + intervals_contradict(&pairs[3].0, &pairs[3].1).expect("after"), + ]; + let collapsed = [false, false, false, false]; + let agreed = contradiction_agreement_rate(&truth, &decided).expect("agreement"); + let collapsed_rate = contradiction_agreement_rate(&truth, &collapsed).expect("collapsed"); + assert!((agreed - 1.0).abs() < f64::EPSILON); + assert!(agreed > collapsed_rate); +} + +#[test] +fn empty_or_mismatched_agreement_slices_fail_closed() { + assert_eq!( + contradiction_agreement_rate(&[], &[]), + Err(PredictionContradictionError::AgreementSliceMismatch) + ); + assert_eq!( + contradiction_agreement_rate(&[true], &[]), + Err(PredictionContradictionError::AgreementSliceMismatch) + ); + assert_eq!( + contradiction_agreement_rate(&[true, false], &[true]), + Err(PredictionContradictionError::AgreementSliceMismatch) + ); +} + +#[test] +fn availability_equal_to_cutoff_remains_eligible() { + let predicted = closed_event_interval(0, 10); + let overlapping = closed_event_interval(5, 15); + let covering = closed_event_interval(0, 10); + let observed_available = available("2026-01-03T00:00:00Z"); + let knowledge_cutoff = cutoff("2026-01-03T00:00:00Z"); + refuse_contradiction_or_adjacency( + &predicted, + &overlapping, + observed_available, + knowledge_cutoff, + ) + .expect("available == cutoff is eligible for the contradiction filter"); + require_observed_coverage(&predicted, &covering, observed_available, knowledge_cutoff) + .expect("available == cutoff is eligible for coverage"); + refuse_promotion(&predicted, &covering, observed_available, knowledge_cutoff) + .expect("available == cutoff is eligible for promotion"); +} + +#[test] +fn after_and_overlapped_by_use_the_same_promotion_rules() { + let predicted = closed_event_interval(20, 30); + let earlier_observed = closed_event_interval(0, 10); + let overlapped_by = closed_event_interval(5, 15); + let later_predicted = closed_event_interval(10, 20); + let (observed_available, knowledge_cutoff) = eligible_clocks(); + + assert!(intervals_contradict(&predicted, &earlier_observed).expect("after")); + assert_eq!( + refuse_contradiction_or_adjacency( + &predicted, + &earlier_observed, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionContradictsObservation) + ); + assert_eq!( + refuse_promotion( + &predicted, + &earlier_observed, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionContradictsObservation) + ); + assert_eq!( + classify_promotion_support(&later_predicted, &overlapped_by).expect("overlapped_by"), + PromotionSupport::PartialOverlap + ); + refuse_contradiction_or_adjacency( + &later_predicted, + &overlapped_by, + observed_available, + knowledge_cutoff, + ) + .expect("overlapped_by is not Allen contradiction"); + assert_eq!( + refuse_promotion( + &later_predicted, + &overlapped_by, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + ); + assert_eq!( + require_observed_coverage( + &later_predicted, + &overlapped_by, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::PredictionNotCoveredByObservation) + ); +} + +#[test] +fn half_open_observed_interval_is_not_an_allen_input() { + let predicted = closed_event_interval(0, 10); + let observed = TemporalInterval::bounded( + TemporalBoundary::Included(event_at(5)), + TemporalBoundary::Excluded(event_at(15)), + TemporalPrecision::Second, + ) + .expect("half-open observed interval is representable"); + let (observed_available, knowledge_cutoff) = eligible_clocks(); + assert_eq!( + intervals_contradict(&predicted, &observed), + Err(PredictionContradictionError::InvalidIntervalPayload) + ); + assert_eq!( + refuse_contradiction_or_adjacency( + &predicted, + &observed, + observed_available, + knowledge_cutoff + ), + Err(PredictionContradictionError::InvalidIntervalPayload) + ); + assert_eq!( + refuse_promotion(&predicted, &observed, observed_available, knowledge_cutoff), + Err(PredictionContradictionError::InvalidIntervalPayload) + ); + assert_eq!( + classify_promotion_support(&predicted, &observed), + Err(PredictionContradictionError::InvalidIntervalPayload) + ); + assert_eq!( + require_observed_coverage(&predicted, &observed, observed_available, knowledge_cutoff), + Err(PredictionContradictionError::InvalidIntervalPayload) + ); +} + +#[test] +fn known_allen_pairs_recover_coverage_and_contradiction_labels() { + let cases = [ + ( + closed_event_interval(0, 10), + closed_event_interval(20, 30), + PromotionSupport::ContradictoryDisjoint, + ), + ( + closed_event_interval(20, 30), + closed_event_interval(0, 10), + PromotionSupport::ContradictoryDisjoint, + ), + ( + closed_event_interval(0, 10), + closed_event_interval(10, 20), + PromotionSupport::AdjacentWithoutOverlap, + ), + ( + closed_event_interval(10, 20), + closed_event_interval(0, 10), + PromotionSupport::AdjacentWithoutOverlap, + ), + ( + closed_event_interval(0, 10), + closed_event_interval(5, 15), + PromotionSupport::PartialOverlap, + ), + ( + closed_event_interval(10, 20), + closed_event_interval(5, 15), + PromotionSupport::PartialOverlap, + ), + ( + closed_event_interval(0, 10), + closed_event_interval(0, 8), + PromotionSupport::PartialOverlap, + ), + ( + closed_event_interval(0, 10), + closed_event_interval(2, 8), + PromotionSupport::PartialOverlap, + ), + ( + closed_event_interval(0, 10), + closed_event_interval(2, 10), + PromotionSupport::PartialOverlap, + ), + ( + closed_event_interval(0, 8), + closed_event_interval(0, 20), + PromotionSupport::ObservedCoversPrediction, + ), + ( + closed_event_interval(2, 8), + closed_event_interval(0, 20), + PromotionSupport::ObservedCoversPrediction, + ), + ( + closed_event_interval(2, 10), + closed_event_interval(0, 10), + PromotionSupport::ObservedCoversPrediction, + ), + ( + closed_event_interval(0, 10), + closed_event_interval(0, 10), + PromotionSupport::ObservedCoversPrediction, + ), + ]; + let (observed_available, knowledge_cutoff) = eligible_clocks(); + let mut truth = Vec::new(); + let mut decided = Vec::new(); + for (predicted, observed, expected) in cases { + let support = classify_promotion_support(&predicted, &observed).expect("label"); + assert_eq!(support, expected); + truth.push(expected == PromotionSupport::ObservedCoversPrediction); + decided.push( + require_observed_coverage(&predicted, &observed, observed_available, knowledge_cutoff) + .is_ok(), + ); + } + let agreed = contradiction_agreement_rate(&truth, &decided).expect("coverage agreement"); + assert!((agreed - 1.0).abs() < f64::EPSILON); +} diff --git a/crates/prediction_contradiction/tests/crate_contract.rs b/crates/prediction_contradiction/tests/crate_contract.rs new file mode 100644 index 000000000..2d414c63c --- /dev/null +++ b/crates/prediction_contradiction/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `prediction_contradiction` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "prediction_contradiction"); +} diff --git a/docs/DOCUMENTATION_ASSESSMENT.md b/docs/DOCUMENTATION_ASSESSMENT.md index 2a14a651f..a0d205925 100644 --- a/docs/DOCUMENTATION_ASSESSMENT.md +++ b/docs/DOCUMENTATION_ASSESSMENT.md @@ -91,9 +91,9 @@ The canonical graph explicitly preserves: Documentation completeness must not be confused with product completeness. -- **implemented-main:** Rust workspace/quality foundation, immutable evidence/exact-span boundary, typed six-clock/uncertain interval foundation (PR #8), and canonical documentation/ADR authority graph through PR #7/#8. -- **active-PR:** PR #9 Allen relation algebra and bounded path-consistency reasoner replayed onto protected-main temporal foundation; promote only after exact-head gates and merge. -- **accepted-target:** Event ontology/graph, multilevel estimators beyond the membership network surface, persistence/splits, multilingual semantic units, TRSL-TM topic measurement, GPU compute, model selection, TDT/CHRONOS, ESEM/DSEM, networks/clusters, interpretation, visual analytics, autonomous product-development authority, and production service APIs. +- **implemented-main:** Rust workspace/quality foundation, immutable evidence/exact-span boundary, typed six-clock/uncertain interval foundation (PR #8), Allen algebra/path-consistency (PR #9), event ontology/membership, and PostgreSQL persistence through restore-integrity probes. +- **active-PR:** `prediction_contradiction` Allen coverage gate; `refuse_promotion` requires observed coverage before unmatched predicted mass may be authorized for promotion. Drafts #93, #94, #97, #101, #102, #104, #108, #109, and #111 are superseded non-landable lineage. Promote only after exact-head gates and merge. +- **accepted-target:** Remaining TDT/CHRONOS tasks, multilevel estimators beyond the membership network surface, multilingual semantic units, TRSL-TM topic measurement, GPU compute, model selection, ESEM/DSEM, networks/clusters, interpretation, visual analytics, autonomous product-development authority, and production service APIs. - **partial:** selected repository-quality and standalone crate boundaries are implemented, while complete estimator/service/release authorities remain target work. - **deployment-owned/external-assurance:** production infrastructure controls, measured SLO/RPO/RTO, CSAP certification, SOC 2 attestation and jurisdiction-specific legal determinations. diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index c29d97433..4a16d48d3 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -30,7 +30,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | compositional topic correlation / stable clustering | ADR 0005/0012; research | future `network_analysis` | accepted-target | | posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | future `psychometric_core` | accepted-target | | CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | future `compute_backend` | accepted-target | -| TDT detection/tracking vs CHRONOS schema/prediction/temporal consistency | ADR 0016; PRD/research | future `event_intelligence` | accepted-target | +| 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 | | evidence-bounded LLM interpretation | ADR 0010/0012; PRD | future `interpretation_gateway` | accepted-target | | adaptive direct/verify/committee/conductor test-time compute | ADR 0010; `docs/LLM_ORCHESTRATION.md` | future contextual-orchestrator integration + ablation evidence | accepted-target | | purpose-bound PII handling without blanket masking | ADR 0009; `docs/PRIVACY_DATA_GOVERNANCE.md` | future authorization/persistence/export/provider adapters | accepted-target | diff --git a/docs/UML.md b/docs/UML.md index 456fec770..97c276362 100644 --- a/docs/UML.md +++ b/docs/UML.md @@ -36,7 +36,7 @@ flowchart LR INT --> ART ``` -On current protected main the workspace/evidence foundation and canonical documentation/ADR authority graph are implemented. Typed six-clock values/uncertain intervals are on canonical replacement PR #8. Legacy PR #6 contains Task 4 Allen/path-consistency work on the superseded PR #5 stack and is not current-lineage implementation evidence until replayed and revalidated. Later boxes are accepted-target. +On current protected main the workspace/evidence foundation, six-clock temporal values, Allen algebra/path-consistency, event ontology/membership, and PostgreSQL persistence through restore-integrity probes are implemented. The active-PR `prediction_contradiction` crate is the promotion-authority gate: call `refuse_promotion` before authorizing promotion of unmatched predicted mass. Remaining TDT/CHRONOS, topic, psychometric, and service boxes stay accepted-target. ## Evidence-to-analysis sequence diff --git a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md index b85ee0b4a..80abb8ac2 100644 --- a/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md +++ b/docs/adr/0016-tdt-chronos-event-intelligence-boundary.md @@ -1,7 +1,8 @@ # ADR 0016 — TDT, CHRONOS, and Event Ontology intelligence boundary **Decision status:** Accepted -**Implementation maturity:** accepted-target +**Implementation maturity:** active-PR — bounded predicted-vs-observed Allen promotion gate, including coverage before unmatched predicted mass may be authorized for promotion; TDT detection/tracking, CHRONOS schema extraction, prediction calibration, and path-consistency laws remain accepted-target + **Date:** 2026-08-12 **Supersedes:** None; complements ADR 0002 temporal semantics and ADR 0003 event ontology/membership. @@ -19,6 +20,8 @@ TEPP separates three event-intelligence layers: Transition edges admitted to the state/input-process-outcome graph remain governed by ADR 0002/0003 and cannot be created merely because TDT/CHRONOS predicts or links two events. Retrospective evidence and schema predictions remain provenance/hypothesis edges until independently promoted. +The bounded `prediction_contradiction` crate is the promotion-authority gate for a pairwise predicted-versus-observed closed proper interval. `refuse_promotion` and `require_observed_coverage` succeed only when later-available evidence covers every predicted instant (`during`, `starts`, `finishes`, or `equals`). `refuse_contradiction_or_adjacency` answers only whether the pair is Allen `before`/`after` or `meets`/`met_by`; its `Ok(())` is not authority to promote unmatched predicted mass. + ## Alternatives considered 1. **Single end-to-end event graph with no evidence-state distinction** — rejected because observation, inference, prediction, and transition authority become conflated. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1a9a7b315..1f149d0a6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,7 +21,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0013](0013-bitemporal-persistence-reproducibility-and-split-authority.md) | Bitemporal persistence, reproducibility manifests, and relation-aware split authority | Accepted | partial | Owns PostgreSQL adapter semantics, immutable run/split manifests, leakage-safe partitions, and recovery identity; optional `live-sqlx` `PgPool`, live PG CI, and tenant RLS implemented; full physical ERD remaining. | | [0014](0014-scientific-claim-promotion-and-release-evidence.md) | Scientific claim promotion and release evidence authority | Accepted | partial | Separates design, implementation, scientific/product claim, and release authority; repository SBOM/provenance generator implemented, full release bundle remaining. | | [0015](0015-autonomous-development-review-and-merge-authority.md) | Autonomous development, review, and merge authority separation | Accepted | active-PR | Separates model proposal, deterministic verification, publication, independent review, and merge/release authority. | -| [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | accepted-target | Separates observed evidence, detection/tracking, prediction/schema inference, temporal consistency, and promoted transition authority. | +| [0016](0016-tdt-chronos-event-intelligence-boundary.md) | TDT, CHRONOS, and Event Ontology intelligence boundary | Accepted | active-PR | Bounded predicted-vs-observed Allen promotion gate: `refuse_promotion` requires observed coverage; remaining TDT/CHRONOS tasks stay accepted-target. | ## Decision ownership summary diff --git a/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md b/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md index 652acfe21..aa2d78431 100644 --- a/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md +++ b/docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md @@ -20,6 +20,28 @@ run may print the task contract without either credential. When a PR exists, normal review → repair → exact-head Checks → merge governance owns the hour. The scheduler does not create a competing branch. +Current executable queue while drafts remain open: + +1. Merge the predicted-versus-observed Allen coverage gate + (`prediction_contradiction` / the coverage-authority landing PR). + `refuse_promotion` requires coverage. Canonical docs name the crate, + not a superseded draft. Keep PR #93, PR #94, PR #97, PR #101, + PR #102, PR #104, PR #108, PR #109, and PR #111 unmerged: #93/#94 + still accept unmatched predicted mass from `refuse_promotion`, #97 + still names PR #94 as a landable authority pointer, #101/#102 still + name a draft as the landable gate, #104 omits later citation-repair + drafts from the unmerged set, #108 still treats #104 as landable, + #109 still omits #108, and #111 still omits the naruon PR #107 lock. +2. Next buyer-visible slices, in order: naruon live HTTP loopback (PR #107; + keep PR #87 and PR #105 unmerged), + `text_segment` SQL contracts on existing migration `0006`, retention and + legal-hold migration `0007` (PR #45), foundation known-truth recovery + study, then CHRONOS forecast Brier calibration (PR #85). +3. Do not open a competing hourly proposal until the open-PR inventory is + empty. Prefer reviewing, repairing, and merging the coverage-authority + landing PR. Keep PR #93, PR #94, PR #97, PR #101, PR #102, PR #104, + PR #108, PR #109, and PR #111 unmerged. + ## Required repository configuration Configure these repository or organization values: diff --git a/docs/research/prediction-contradiction-gate.md b/docs/research/prediction-contradiction-gate.md new file mode 100644 index 000000000..b80628371 --- /dev/null +++ b/docs/research/prediction-contradiction-gate.md @@ -0,0 +1,63 @@ +# Predicted-versus-observed temporal contradiction + +## Scope + +`prediction_contradiction` is a promotion policy over +`temporal_core::classify_interval_relation`. A predicted closed proper +event-time interval cannot become observed fact when the Allen relation is +`before` or `after` (contradiction) or `meets` / `met_by` (adjacent, no +interior overlap). Partial overlap (`overlaps`, `overlapped_by`, `contains`, +`started_by`, `finished_by`) is not a network contradiction, but it leaves +unmatched predicted mass. `refuse_promotion` and `require_observed_coverage` +therefore succeed only for `during`, `starts`, `finishes`, and `equals`. +`refuse_contradiction_or_adjacency` is the weaker contradiction/adjacency +filter; do not call it to authorize promotion. Observed evidence whose +availability time exceeds the analysis knowledge cutoff is ineligible. + +Label agreement on those flags is a helper for the gate. It is not RMSE, +bias, or interval-coverage recovery against a generative truth process. + +This slice does not run the `temporal_core` path-consistency reasoner, fit +CHRONOS schemas, extract TDT tracks, or claim that the full ADR 0016 +intelligence stack is implemented. + +```mermaid +flowchart TD + cutoff{available <= cutoff?} + allen[classify_interval_relation] + contradict[Refuse: before / after] + adjacent[Refuse: meets / met_by] + partial[Refuse coverage: unmatched predicted mass] + cover[refuse_promotion: coverage may authorize promotion] + cutoff -->|no| ineligible[Refuse: evidence after cutoff] + cutoff -->|yes| allen + allen --> contradict + allen --> adjacent + allen --> partial + allen --> cover +``` + +Next action: call `refuse_promotion` (or `require_observed_coverage`) before +promoting a forecast. `refuse_contradiction_or_adjacency` only answers whether +the pair is contradictory or adjacent. + +## Authority + +### Normative TEPP contract + +- `docs/adr/0016-tdt-chronos-event-intelligence-boundary.md` — predictions + remain hypothetical until supported by later evidence; temporal + contradiction can reject a proposed promotion. +- `docs/adr/0002-six-clock-temporal-semantics.md` — event/valid time is + the clock for occurrence intervals; availability may not exceed cutoff. + +### Supporting literature + +Allen (1983) defines thirteen interval relations. `before` and `after` are +strictly disjoint with a gap. `meets` and `met_by` share an endpoint and are +not network contradictions. This crate uses that distinction for promotion +and does not implement the composition table or path consistency. + +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. +*Communications of the ACM, 26*(11), 832–843. +https://doi.org/10.1145/182.358434 diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index b4b144684..67d0945f8 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -66,6 +66,8 @@ Allan, J. (Ed.). (2002). *Topic detection and tracking: Event-based information Anagnostopoulos, E., Batsakis, S., & Petrakis, E. G. M. (2013). CHRONOS: A reasoning engine for qualitative temporal information in OWL. *Procedia Computer Science, 22*, 70–77. https://doi.org/10.1016/j.procs.2013.09.082 +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434. `temporal_core` owns the thirteen elementary relations and composition; `prediction_contradiction` uses `before`/`after` as contradiction, `meets`/`met_by` as unsupported adjacency, and `during`/`starts`/`finishes`/`equals` as the only coverage relations that can authorize promotion. + TEPP uses interval and partial-order reasoning, bitemporal availability, leakage-safe cutoffs, TDT segmentation/link/detection/first-story/tracking tasks, and separate neural/symbolic event-schema and temporal-consistency layers. ## Unicode, language tags, and multilingual structure diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 295fbae0f..cf4475ccd 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -23,6 +23,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Truth corpora / manifests | `tepp_simulation` | implemented-main | — | deterministic generator tests | Task 10 / PR #18 | | Recovery metrics | `validation_core` | implemented-main | — | RMSE/bias/coverage/MC gates | Task 11 / PR #19 | | Versioned API/export contracts | `tepp_api` | implemented-main | naruon HTTP interchange | unknown-field/version/limit + naruon HTTPS interchange tests | Task 12 / PR #21; live HTTP service remaining | +| Predicted-vs-observed contradiction | `prediction_contradiction` | active-PR | this PR | `refuse_promotion` requires observed coverage; `refuse_contradiction_or_adjacency` is not promotion authority; cutoff eligibility; label agreement is not RMSE recovery | ADR 0016 | | CWL modular connectors | `docs/connectors/*` | implemented-main | — | contract docs + examples | PR #22; live HTTP ports remaining | | Release SBOM/provenance generator | `scripts/release_evidence.py` | partial | — | generate+validate in CI | Task 13 partial / PR #28 | diff --git a/scripts/check_workspace_contract.py b/scripts/check_workspace_contract.py index c7b1ecf58..2e7a024a9 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -23,6 +23,7 @@ "tepp_simulation", "validation_core", "tepp_api", + "prediction_contradiction", ) REQUIRED_CI_SNIPPETS: tuple[str, ...] = ( diff --git a/scripts/validate_documentation.py b/scripts/validate_documentation.py index c0603c4d6..178226778 100644 --- a/scripts/validate_documentation.py +++ b/scripts/validate_documentation.py @@ -76,6 +76,32 @@ "## Consequences", "## Verification", ) +STALE_COVERAGE_GATE_PARENTHETICAL = re.compile( + r"prediction_contradiction`? \(PR #\d+\)" +) +STALE_ACTIVE_PR_COVERAGE_GATE = re.compile(r"\*\*active-PR:\*\*\s*PR #\d+\b") +STALE_LANDABLE_COVERAGE_GATE = re.compile( + r"landable coverage gate is PR #\d+\b", + re.IGNORECASE, +) +STALE_REFUSE_PROMOTION_DRAFT_AUTHORITY = re.compile( + r"refuse_promotion`? in PR #\d+ is the coverage authority" +) +STALE_MERGE_WEAK_DRAFTS = re.compile(r"merging the existing drafts") +UNMERGED_QUEUE_SENTENCE = re.compile(r"[^.]*unmerged[^.]*", re.IGNORECASE) +REQUIRED_UNMERGED_COVERAGE_DRAFTS = (93, 94, 97, 101, 102, 104, 108, 109, 111) +AUTHORITY_POINTER_FILES = ( + "DOCUMENTATION.md", + "docs/DOCUMENTATION_ASSESSMENT.md", + "docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md", + "docs/TRACEABILITY.md", + "docs/UML.md", + "docs/adr/0016-tdt-chronos-event-intelligence-boundary.md", + "CHANGELOG.md", + "ARCHITECTURE.md", + "README.md", + "docs/adr/README.md", +) CANONICAL_LINKS = ( "docs/product/prd-v0.4-approved.md", @@ -119,6 +145,134 @@ def validate_required_files() -> None: raise AssertionError(f"missing required documentation: {missing}") +def _document_has_stale_coverage_authority(text: str) -> bool: + """Return whether one document names a superseded draft as the coverage gate.""" + + return bool( + STALE_COVERAGE_GATE_PARENTHETICAL.search(text) + or STALE_LANDABLE_COVERAGE_GATE.search(text) + or STALE_REFUSE_PROMOTION_DRAFT_AUTHORITY.search(text) + ) + + +def _hourly_unmerged_text(hourly: str) -> str: + """Return Keep-unmerged sentences so later drafts cannot hide outside the lock.""" + + collapsed = hourly.replace("\n", " ") + return " ".join(UNMERGED_QUEUE_SENTENCE.findall(collapsed)) + + +def _hourly_queue_lock_failures(hourly: str) -> list[str]: + """Return queue-lock failures when hourly names a coverage or naruon pointer. + + The phrase lock already rejects `landable coverage gate is PR #N`. This + queue lock refuses an unmerged list that stops at #101/#102, and refuses a + naruon pointer that is not PR #107 with #87 and #105 kept unmerged. + """ + + if not hourly: + return [] + looks_like_queue = "unmerged" in hourly.casefold() or "naruon" in hourly.casefold() + if not looks_like_queue: + return [] + failures: list[str] = [] + joined = _hourly_unmerged_text(hourly) + if any( + f"PR #{number}" not in joined for number in REQUIRED_UNMERGED_COVERAGE_DRAFTS + ): + failures.append( + "docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md omits later " + "coverage-authority drafts from the unmerged set" + ) + if ( + "PR #107" not in hourly + or "PR #105" not in joined + or "PR #87" not in joined + ): + failures.append( + "docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md points naruon " + "live HTTP away from PR #107" + ) + return failures + + +def promotion_authority_failures( + documentation: str, + assessment: str, + hourly: str = "", + extra_documents: dict[str, str] | None = None, +) -> list[str]: + """Return stale pointers that name a superseded draft as the coverage gate. + + A pull-request number is not landable coverage authority. Canonical docs + and the hourly queue must name the `prediction_contradiction` crate, not + a draft such as #93, #94, #97, #101, #102, #104, #108, #109, or #111. + """ + + failures: list[str] = [] + if _document_has_stale_coverage_authority(documentation): + failures.append( + "DOCUMENTATION.md names a superseded draft as the coverage-gate authority" + ) + if STALE_ACTIVE_PR_COVERAGE_GATE.search(assessment) or ( + _document_has_stale_coverage_authority(assessment) + ): + failures.append( + "docs/DOCUMENTATION_ASSESSMENT.md names a superseded draft as the " + "active-PR coverage gate" + ) + if STALE_MERGE_WEAK_DRAFTS.search(hourly) or ( + _document_has_stale_coverage_authority(hourly) + ): + failures.append( + "docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md tells the " + "queue to merge superseded coverage drafts" + ) + failures.extend(_hourly_queue_lock_failures(hourly)) + for path, text in (extra_documents or {}).items(): + if _document_has_stale_coverage_authority(text) or ( + STALE_ACTIVE_PR_COVERAGE_GATE.search(text) + ): + failures.append( + f"{path} names a superseded draft as the coverage-gate authority" + ) + return failures + + +def validate_promotion_authority_pointers() -> None: + """Refuse canonical docs that still treat a pull request as landable authority.""" + + missing = [ + relative + for relative in AUTHORITY_POINTER_FILES + if not (ROOT / relative).is_file() + ] + if missing: + raise AssertionError(f"missing promotion-authority documents: {missing}") + texts = { + relative: (ROOT / relative).read_text(encoding="utf-8") + for relative in AUTHORITY_POINTER_FILES + } + extra_documents = { + path: text + for path, text in texts.items() + if path + not in { + "DOCUMENTATION.md", + "docs/DOCUMENTATION_ASSESSMENT.md", + "docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md", + } + } + failures = promotion_authority_failures( + texts["DOCUMENTATION.md"], + texts["docs/DOCUMENTATION_ASSESSMENT.md"], + hourly=texts["docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md"], + extra_documents=extra_documents, + ) + if failures: + raise AssertionError("\n".join(failures)) + + def validate_documentation_map() -> None: """Require cross-cutting canonical documents to be discoverable from the root map.""" @@ -234,6 +388,7 @@ def main() -> None: """Run all deterministic documentation validation groups.""" validate_required_files() + validate_promotion_authority_pointers() validate_documentation_map() validate_adr_graph() validate_markdown() diff --git a/tests/quality/test_check_docstrings.py b/tests/quality/test_check_docstrings.py index 2c11f7a53..56d553d27 100644 --- a/tests/quality/test_check_docstrings.py +++ b/tests/quality/test_check_docstrings.py @@ -11,6 +11,7 @@ from unittest import mock from scripts import check_docstrings as docstrings +from scripts import check_workspace_contract as contract REPOSITORY_ROOT = Path(__file__).resolve().parents[2] @@ -24,7 +25,7 @@ def test_live_repository_is_documented(self) -> None: sources = docstrings.rust_sources(REPOSITORY_ROOT) crate_roots = sorted(REPOSITORY_ROOT.glob("crates/*/src/lib.rs")) - self.assertEqual(len(crate_roots), 10) + self.assertEqual(len(crate_roots), len(contract.EXPECTED_CRATES)) self.assertTrue(set(crate_roots).issubset(sources)) self.assertGreaterEqual(len(sources), len(crate_roots)) self.assertEqual(docstrings.validate_repository(REPOSITORY_ROOT), []) diff --git a/tests/quality/test_hourly_nim_product_development.py b/tests/quality/test_hourly_nim_product_development.py index 7c56183ca..3a3a8cd80 100644 --- a/tests/quality/test_hourly_nim_product_development.py +++ b/tests/quality/test_hourly_nim_product_development.py @@ -228,6 +228,23 @@ def test_supporting_runbook_and_doctoring_exist(self) -> None: self.assertIn("APA", doctoring) self.assertIn("Do not configure `COPILOT_GITHUB_TOKEN`", runbook) + def test_hourly_queue_keeps_weaker_coverage_locks_unmerged(self) -> None: + """A runner must not treat #104, #108, #109, or #111 as the landable gate.""" + + runbook = _text(RUNBOOK) + unmerged_sentences = [ + sentence + for sentence in runbook.replace("\n", " ").split(".") + if "unmerged" in sentence.casefold() + ] + joined = " ".join(unmerged_sentences) + for pull_request in (93, 94, 97, 101, 102, 104, 108, 109, 111): + with self.subTest(pull_request=pull_request): + self.assertIn(f"PR #{pull_request}", joined) + self.assertIn("PR #107", runbook) + self.assertIn("PR #105", joined) + self.assertIn("PR #87", joined) + if __name__ == "__main__": unittest.main() diff --git a/tests/quality/test_validate_documentation.py b/tests/quality/test_validate_documentation.py new file mode 100644 index 000000000..9f6a6239d --- /dev/null +++ b/tests/quality/test_validate_documentation.py @@ -0,0 +1,394 @@ +"""Tests for repository documentation contracts, including promotion authority.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from scripts import validate_documentation as documentation + + +class PromotionAuthorityPointerTests(unittest.TestCase): + """Refuse canonical docs that name a superseded draft as the coverage gate.""" + + def test_stale_pr94_pointers_fail_closed(self) -> None: + """The #94 wording that made refuse_promotion look landable is rejected.""" + + stale_documentation = ( + "The active-PR coverage gate in `prediction_contradiction` (PR #94) " + "requires observed Allen coverage." + ) + stale_assessment = ( + "- **active-PR:** PR #94 `prediction_contradiction` Allen coverage gate" + ) + self.assertEqual( + documentation.promotion_authority_failures( + stale_documentation, stale_assessment + ), + [ + "DOCUMENTATION.md names a superseded draft as the coverage-gate authority", + "docs/DOCUMENTATION_ASSESSMENT.md names a superseded draft as the " + "active-PR coverage gate", + ], + ) + + def test_stale_pr93_pointers_fail_closed(self) -> None: + """The earlier contradiction-only draft is also not landable authority.""" + + stale_documentation = ( + "The active-PR coverage gate in `prediction_contradiction` (PR #93) " + "requires observed Allen coverage." + ) + stale_assessment = ( + "- **active-PR:** PR #93 `prediction_contradiction` Allen coverage gate" + ) + self.assertEqual( + documentation.promotion_authority_failures( + stale_documentation, stale_assessment + ), + [ + "DOCUMENTATION.md names a superseded draft as the coverage-gate authority", + "docs/DOCUMENTATION_ASSESSMENT.md names a superseded draft as the " + "active-PR coverage gate", + ], + ) + + def test_one_sided_stale_pointers_fail_independently(self) -> None: + """Each canonical file is checked even when the other is already current.""" + + self.assertEqual( + documentation.promotion_authority_failures( + "The active-PR coverage gate in `prediction_contradiction` (PR #94).", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + ), + [ + "DOCUMENTATION.md names a superseded draft as the coverage-gate authority" + ], + ) + self.assertEqual( + documentation.promotion_authority_failures( + "The active-PR coverage gate in `prediction_contradiction` requires coverage.", + "- **active-PR:** PR #94 `prediction_contradiction` Allen coverage gate", + ), + [ + "docs/DOCUMENTATION_ASSESSMENT.md names a superseded draft as the " + "active-PR coverage gate" + ], + ) + + def test_stale_pr97_pointers_fail_closed(self) -> None: + """The #97 pointer-repair draft is also not landable authority.""" + + stale_documentation = ( + "The active-PR coverage gate in `prediction_contradiction` (PR #97) " + "requires observed Allen coverage." + ) + stale_assessment = ( + "- **active-PR:** PR #97 `prediction_contradiction` Allen coverage gate" + ) + self.assertEqual( + documentation.promotion_authority_failures( + stale_documentation, stale_assessment + ), + [ + "DOCUMENTATION.md names a superseded draft as the coverage-gate authority", + "docs/DOCUMENTATION_ASSESSMENT.md names a superseded draft as the " + "active-PR coverage gate", + ], + ) + + def test_parenthetical_without_backtick_fails(self) -> None: + """A missing markdown fence must not hide a draft authority pointer.""" + + self.assertEqual( + documentation.promotion_authority_failures( + "The landable gate is prediction_contradiction (PR #94).", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + ), + [ + "DOCUMENTATION.md names a superseded draft as the coverage-gate authority" + ], + ) + + def test_landable_and_refuse_promotion_authority_sentences_fail(self) -> None: + """Plain-language authority sentences are rejected even without markdown.""" + + self.assertEqual( + documentation.promotion_authority_failures( + "The landable coverage gate is PR #94.", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + ), + [ + "DOCUMENTATION.md names a superseded draft as the coverage-gate authority" + ], + ) + self.assertEqual( + documentation.promotion_authority_failures( + "`refuse_promotion` in PR #94 is the coverage authority.", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + ), + [ + "DOCUMENTATION.md names a superseded draft as the coverage-gate authority" + ], + ) + + def test_citation_repair_drafts_are_not_landable_authority(self) -> None: + """#101, #102, #104, and #108 still name a draft as the gate.""" + + for pull_request in (101, 102, 104, 108): + with self.subTest(pull_request=pull_request): + self.assertEqual( + documentation.promotion_authority_failures( + f"The landable coverage gate is PR #{pull_request}.", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + ), + [ + "DOCUMENTATION.md names a superseded draft as the " + "coverage-gate authority" + ], + ) + self.assertEqual( + documentation.promotion_authority_failures( + ( + "The landable gate is prediction_contradiction " + f"(PR #{pull_request})." + ), + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + ), + [ + "DOCUMENTATION.md names a superseded draft as the " + "coverage-gate authority" + ], + ) + self.assertEqual( + documentation.promotion_authority_failures( + ( + "`refuse_promotion` in PR " + f"#{pull_request} is the coverage authority." + ), + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + ), + [ + "DOCUMENTATION.md names a superseded draft as the " + "coverage-gate authority" + ], + ) + + def test_changelog_and_architecture_are_scanned(self) -> None: + """CHANGELOG and ARCHITECTURE cannot rename a draft as the landable gate.""" + + self.assertEqual( + documentation.promotion_authority_failures( + "The active-PR coverage gate in `prediction_contradiction` requires coverage.", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + extra_documents={ + "CHANGELOG.md": "The landable coverage gate is PR #102.", + "ARCHITECTURE.md": ( + "gate in prediction_contradiction (PR #101) requires coverage" + ), + }, + ), + [ + "CHANGELOG.md names a superseded draft as the coverage-gate authority", + "ARCHITECTURE.md names a superseded draft as the coverage-gate authority", + ], + ) + + def test_hourly_merge_existing_drafts_fails(self) -> None: + """The queue must not treat #93/#94/#97 as mergeable drafts.""" + + self.assertEqual( + documentation.promotion_authority_failures( + "The active-PR coverage gate in `prediction_contradiction` requires coverage.", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + hourly=( + "Prefer reviewing, repairing, and merging the existing drafts." + ), + ), + [ + "docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md tells the " + "queue to merge superseded coverage drafts" + ], + ) + + def test_extra_canonical_files_are_scanned(self) -> None: + """ADR, TRACEABILITY, and UML cannot rename a draft as the gate.""" + + self.assertEqual( + documentation.promotion_authority_failures( + "The active-PR coverage gate in `prediction_contradiction` requires coverage.", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + extra_documents={ + "docs/TRACEABILITY.md": ( + "The landable coverage gate is PR #94." + ) + }, + ), + [ + "docs/TRACEABILITY.md names a superseded draft as the " + "coverage-gate authority" + ], + ) + + def test_hourly_unmerged_set_omitting_later_drafts_fails(self) -> None: + """#104, #108, #109, and #111 must appear in Keep-unmerged sentences.""" + + self.assertEqual( + documentation.promotion_authority_failures( + "The active-PR coverage gate in `prediction_contradiction` requires coverage.", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + hourly=( + "Keep PR #93, PR #94, PR #97, PR #101, PR #102, PR #104, and " + "PR #108 unmerged. naruon live HTTP loopback (PR #107; " + "keep PR #87 and PR #105 unmerged)" + ), + ), + [ + "docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md omits later " + "coverage-authority drafts from the unmerged set" + ], + ) + + def test_hourly_naruon_pointer_away_from_107_fails(self) -> None: + """The next buyer slice must stay on the live loopback listener, not #105.""" + + self.assertEqual( + documentation.promotion_authority_failures( + "The active-PR coverage gate in `prediction_contradiction` requires coverage.", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + hourly=( + "Keep PR #93, PR #94, PR #97, PR #101, PR #102, PR #104, " + "PR #108, PR #109, and PR #111 unmerged. naruon live HTTP " + "loopback (PR #105; keep PR #87 unmerged)" + ), + ), + [ + "docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md points naruon " + "live HTTP away from PR #107" + ], + ) + + def test_crate_named_authority_and_draft_lineage_pass(self) -> None: + """Naming the crate, and mentioning drafts as non-landable, is allowed.""" + + current_documentation = ( + "The active-PR coverage gate in `prediction_contradiction` requires " + "observed Allen coverage. Drafts #93, #94, and #97 are not landable " + "while they still point at PR #94 as the authority." + ) + current_assessment = ( + "- **active-PR:** `prediction_contradiction` Allen coverage gate; " + "refuse_promotion requires observed coverage." + ) + current_hourly = ( + "Keep PR #93, PR #94, PR #97, PR #101, PR #102, PR #104, " + "PR #108, PR #109, and PR #111 unmerged. Prefer merging the " + "coverage-authority landing PR. naruon live HTTP loopback " + "(PR #107; keep PR #87 and PR #105 unmerged)" + ) + self.assertEqual( + documentation.promotion_authority_failures( + current_documentation, + current_assessment, + hourly=current_hourly, + ), + [], + ) + + def test_live_repository_does_not_name_superseded_drafts(self) -> None: + """Current canonical files pass the coverage-authority pointer contract.""" + + documentation.validate_promotion_authority_pointers() + + def test_assessment_parenthetical_and_hourly_parenthetical_fail(self) -> None: + """Assessment and hourly files fail on parenthetical draft pointers too.""" + + self.assertEqual( + documentation.promotion_authority_failures( + "The active-PR coverage gate in `prediction_contradiction` requires coverage.", + "gate in `prediction_contradiction` (PR #94) requires coverage", + ), + [ + "docs/DOCUMENTATION_ASSESSMENT.md names a superseded draft as the " + "active-PR coverage gate" + ], + ) + self.assertEqual( + documentation.promotion_authority_failures( + "The active-PR coverage gate in `prediction_contradiction` requires coverage.", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + hourly="gate in `prediction_contradiction` (PR #93) requires coverage", + ), + [ + "docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md tells the " + "queue to merge superseded coverage drafts" + ], + ) + + def test_extra_file_active_pr_pointer_fails(self) -> None: + """An extra canonical file with an active-PR draft pointer is rejected.""" + + self.assertEqual( + documentation.promotion_authority_failures( + "The active-PR coverage gate in `prediction_contradiction` requires coverage.", + "- **active-PR:** `prediction_contradiction` Allen coverage gate", + extra_documents={ + "docs/UML.md": "- **active-PR:** PR #97 `prediction_contradiction`" + }, + ), + [ + "docs/UML.md names a superseded draft as the coverage-gate authority" + ], + ) + + def test_validate_promotion_authority_pointers_raises_on_missing_files( + self, + ) -> None: + """File-backed validation fail-closes when an authority document is absent.""" + + with tempfile.TemporaryDirectory() as temporary: + with mock.patch.object(documentation, "ROOT", Path(temporary)): + with self.assertRaises(AssertionError) as raised: + documentation.validate_promotion_authority_pointers() + self.assertIn("missing promotion-authority documents", str(raised.exception)) + + def test_validate_promotion_authority_pointers_raises_on_stale_files( + self, + ) -> None: + """File-backed validation fail-closes when either canonical file is stale.""" + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "DOCUMENTATION.md").write_text( + "gate in `prediction_contradiction` (PR #94) requires coverage\n", + encoding="utf-8", + ) + for relative in ( + "docs/DOCUMENTATION_ASSESSMENT.md", + "docs/operations/HOURLY_NIM_PRODUCT_DEVELOPMENT.md", + "docs/TRACEABILITY.md", + "docs/UML.md", + "docs/adr/0016-tdt-chronos-event-intelligence-boundary.md", + "CHANGELOG.md", + "ARCHITECTURE.md", + "README.md", + "docs/adr/README.md", + ): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "- **active-PR:** PR #94 `prediction_contradiction`\n" + if relative.endswith("DOCUMENTATION_ASSESSMENT.md") + else "crate-named coverage gate\n", + encoding="utf-8", + ) + with mock.patch.object(documentation, "ROOT", root): + with self.assertRaises(AssertionError) as raised: + documentation.validate_promotion_authority_pointers() + self.assertIn("superseded draft", str(raised.exception)) + + +if __name__ == "__main__": + unittest.main()