From d16876151da6ef41371756a3b9b210fd71dc69cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 02:59:38 +0900 Subject: [PATCH] feat(event): refuse subevents that escape the parent interval A half-open subevent window must lie inside the parent event-time interval (ADR 0003). --- ARCHITECTURE.md | 1 + CHANGELOG.md | 1 + Cargo.lock | 4 + Cargo.toml | 2 + README.md | 3 +- crates/subevent_containment/Cargo.toml | 17 +++ crates/subevent_containment/src/error.rs | 46 ++++++ crates/subevent_containment/src/interval.rs | 133 ++++++++++++++++++ crates/subevent_containment/src/lib.rs | 21 +++ .../tests/containment_contract.rs | 79 +++++++++++ .../tests/crate_contract.rs | 7 + docs/TRACEABILITY.md | 2 +- ...03-relational-event-multiple-membership.md | 2 +- docs/adr/README.md | 2 +- docs/research/standards-and-literature.md | 2 + docs/research/subevent-containment.md | 29 ++++ docs/validation/temporal-event-foundation.md | 1 + scripts/check_workspace_contract.py | 1 + 18 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 crates/subevent_containment/Cargo.toml create mode 100644 crates/subevent_containment/src/error.rs create mode 100644 crates/subevent_containment/src/interval.rs create mode 100644 crates/subevent_containment/src/lib.rs create mode 100644 crates/subevent_containment/tests/containment_contract.rs create mode 100644 crates/subevent_containment/tests/crate_contract.rs create mode 100644 docs/research/subevent-containment.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ffe514db6..3fb0d4c98 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -61,6 +61,7 @@ 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 | +| `subevent_containment` | subevent event-time intervals must stay inside the parent | 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c1cc6e879..5ccd36e15 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 +- `subevent_containment` parent-window gate: a half-open subevent interval that starts before or ends after its parent cannot attach; recovered containment flags match known truth at a higher computed rate than accepting every child (ADR 0003). - `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..1f39df6b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1217,6 +1217,10 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "subevent_containment" +version = "0.1.0" + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index 925659406..a4bf5b5bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/subevent_containment", ] default-members = [ "crates/evidence_core", @@ -23,6 +24,7 @@ default-members = [ "crates/tepp_simulation", "crates/validation_core", "crates/tepp_api", + "crates/subevent_containment", ] [workspace.package] diff --git a/README.md b/README.md index ae74015d3..9b6124c38 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ 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 +The eleven bounded crates compile independently but intentionally expose no placeholder production APIs. Domain behavior begins in Task 2 with immutable evidence identifiers and source records. @@ -22,6 +22,7 @@ crates/corpus_split crates/tepp_simulation crates/validation_core crates/tepp_api +crates/subevent_containment ``` ## Local verification diff --git a/crates/subevent_containment/Cargo.toml b/crates/subevent_containment/Cargo.toml new file mode 100644 index 000000000..d9149c355 --- /dev/null +++ b/crates/subevent_containment/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "subevent_containment" +description = "Subevent intervals must stay inside the parent event interval." +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 + +[lints] +workspace = true diff --git a/crates/subevent_containment/src/error.rs b/crates/subevent_containment/src/error.rs new file mode 100644 index 000000000..e2e40754c --- /dev/null +++ b/crates/subevent_containment/src/error.rs @@ -0,0 +1,46 @@ +//! Fail-closed subevent-containment errors. + +use std::fmt; + +/// A fail-closed subevent-containment error. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum SubeventContainmentError { + /// The subevent interval is not inside the parent interval. + SubeventEscapesParent, + /// An interval or recovery slice was empty, inverted, or length-mismatched. + InvalidIntervalPayload, +} + +impl fmt::Display for SubeventContainmentError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::SubeventEscapesParent => "subevent interval escapes the parent event", + Self::InvalidIntervalPayload => "invalid subevent-containment payload", + }; + formatter.write_str(message) + } +} + +impl std::error::Error for SubeventContainmentError {} + +#[cfg(test)] +mod tests { + use super::SubeventContainmentError; + + #[test] + fn error_messages_are_stable() { + for (error, message) in [ + ( + SubeventContainmentError::SubeventEscapesParent, + "subevent interval escapes the parent event", + ), + ( + SubeventContainmentError::InvalidIntervalPayload, + "invalid subevent-containment payload", + ), + ] { + assert_eq!(error.to_string(), message); + } + } +} diff --git a/crates/subevent_containment/src/interval.rs b/crates/subevent_containment/src/interval.rs new file mode 100644 index 000000000..7d4726b9c --- /dev/null +++ b/crates/subevent_containment/src/interval.rs @@ -0,0 +1,133 @@ +//! Half-open event-time intervals and parent containment. + +use crate::SubeventContainmentError; + +/// One half-open event-time interval `[start, end)` in seconds. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EventInterval { + start_seconds: i64, + end_seconds: i64, +} + +impl EventInterval { + /// Construct a half-open interval with a strictly positive length. + /// + /// # Errors + /// + /// Returns [`SubeventContainmentError::InvalidIntervalPayload`] when + /// `end_seconds` is not greater than `start_seconds`. + pub const fn new( + start_seconds: i64, + end_seconds: i64, + ) -> Result { + if end_seconds <= start_seconds { + return Err(SubeventContainmentError::InvalidIntervalPayload); + } + Ok(Self { + start_seconds, + end_seconds, + }) + } + + /// Inclusive start bound in seconds. + #[must_use] + pub const fn start_seconds(self) -> i64 { + self.start_seconds + } + + /// Exclusive end bound in seconds. + #[must_use] + pub const fn end_seconds(self) -> i64 { + self.end_seconds + } +} + +/// Return whether `child` lies entirely inside `parent`. +/// +/// # Errors +/// +/// This function is infallible for validated intervals and exists to keep the +/// public comparison surface explicit. +#[allow(clippy::unnecessary_wraps)] +pub fn interval_contains( + parent: EventInterval, + child: EventInterval, +) -> Result { + Ok(child.start_seconds >= parent.start_seconds && child.end_seconds <= parent.end_seconds) +} + +/// Refuse to attach a subevent that escapes the parent interval. +/// +/// # Errors +/// +/// Returns [`SubeventContainmentError::SubeventEscapesParent`] when the child +/// is not contained. +pub fn refuse_escaped_subevent( + parent: EventInterval, + child: EventInterval, +) -> Result<(), SubeventContainmentError> { + if interval_contains(parent, child)? { + return Ok(()); + } + Err(SubeventContainmentError::SubeventEscapesParent) +} + +/// Fraction of recovered containment flags that match known truth. +/// +/// # Errors +/// +/// Returns [`SubeventContainmentError::InvalidIntervalPayload`] when either +/// slice is empty or the lengths differ. +pub fn containment_recovery_rate( + truth: &[bool], + decided: &[bool], +) -> Result { + if truth.is_empty() || truth.len() != decided.len() { + return Err(SubeventContainmentError::InvalidIntervalPayload); + } + let mut matches = 0_u32; + for (truth_flag, decided_flag) in truth.iter().zip(decided) { + if truth_flag == decided_flag { + matches += 1; + } + } + Ok(f64::from(matches) / truth.len() as f64) +} + +#[cfg(test)] +mod tests { + use super::{ + EventInterval, containment_recovery_rate, interval_contains, refuse_escaped_subevent, + }; + use crate::SubeventContainmentError; + + #[test] + fn local_branches_cover_containment_and_payloads() { + let parent = EventInterval::new(10, 40).expect("parent"); + let inside = EventInterval::new(15, 30).expect("inside"); + assert_eq!(parent.start_seconds(), 10); + assert_eq!(parent.end_seconds(), 40); + assert!(interval_contains(parent, inside).expect("inside")); + refuse_escaped_subevent(parent, inside).expect("contained"); + let early = EventInterval::new(0, 20).expect("early"); + assert!(!interval_contains(parent, early).expect("early")); + assert_eq!( + refuse_escaped_subevent(parent, early), + Err(SubeventContainmentError::SubeventEscapesParent) + ); + assert_eq!( + EventInterval::new(4, 4), + Err(SubeventContainmentError::InvalidIntervalPayload) + ); + let matched = containment_recovery_rate(&[true], &[true]).expect("rate"); + assert!((matched - 1.0).abs() < f64::EPSILON); + assert_eq!( + containment_recovery_rate(&[], &[]), + Err(SubeventContainmentError::InvalidIntervalPayload) + ); + assert_eq!( + containment_recovery_rate(&[true], &[]), + Err(SubeventContainmentError::InvalidIntervalPayload) + ); + } +} diff --git a/crates/subevent_containment/src/lib.rs b/crates/subevent_containment/src/lib.rs new file mode 100644 index 000000000..82e3888e3 --- /dev/null +++ b/crates/subevent_containment/src/lib.rs @@ -0,0 +1,21 @@ +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![allow(clippy::cast_precision_loss)] +//! Subevent intervals must stay inside the parent event interval. +//! +//! A subevent is part of a versioned event instance. Its event-time interval +//! cannot start before or end after the parent (ADR 0003). + +mod error; +mod interval; + +/// Fail-closed subevent-containment errors. +pub use error::SubeventContainmentError; +/// One half-open event-time interval. +pub use interval::EventInterval; +/// Fraction of recovered containment flags that match known truth. +pub use interval::containment_recovery_rate; +/// Return whether a child interval lies entirely inside a parent interval. +pub use interval::interval_contains; +/// Refuse to attach a subevent that escapes the parent interval. +pub use interval::refuse_escaped_subevent; diff --git a/crates/subevent_containment/tests/containment_contract.rs b/crates/subevent_containment/tests/containment_contract.rs new file mode 100644 index 000000000..84b0dc15b --- /dev/null +++ b/crates/subevent_containment/tests/containment_contract.rs @@ -0,0 +1,79 @@ +//! A subevent cannot escape its parent event-time interval. + +use subevent_containment::{ + EventInterval, SubeventContainmentError, containment_recovery_rate, interval_contains, + refuse_escaped_subevent, +}; + +fn interval(start: i64, end: i64) -> EventInterval { + EventInterval::new(start, end).expect("interval") +} + +#[test] +fn escaped_subevents_cannot_attach_to_the_parent() { + let parent = interval(10, 40); + let inside = interval(15, 30); + let early = interval(0, 20); + let late = interval(30, 50); + assert!(interval_contains(parent, inside).expect("inside")); + refuse_escaped_subevent(parent, inside).expect("contained"); + assert!(!interval_contains(parent, early).expect("early")); + assert_eq!( + refuse_escaped_subevent(parent, early), + Err(SubeventContainmentError::SubeventEscapesParent) + ); + assert_eq!( + refuse_escaped_subevent(parent, late), + Err(SubeventContainmentError::SubeventEscapesParent) + ); +} + +#[test] +fn recovered_containment_matches_known_truth_better_than_accepting_all() { + let parent = interval(10, 40); + let children = [interval(15, 30), interval(0, 20), interval(12, 18)]; + let truth = [true, false, true]; + let recovered = [ + interval_contains(parent, children[0]).expect("c0"), + interval_contains(parent, children[1]).expect("c1"), + interval_contains(parent, children[2]).expect("c2"), + ]; + let collapsed = [true, true, true]; + let recovered_rate = containment_recovery_rate(&truth, &recovered).expect("recovered"); + let collapsed_rate = containment_recovery_rate(&truth, &collapsed).expect("collapsed"); + let expected = { + let mut matches = 0_u32; + for (truth_flag, decided_flag) in truth.iter().zip(recovered.iter()) { + if truth_flag == decided_flag { + matches += 1; + } + } + f64::from(matches) / f64::from(u32::try_from(truth.len()).expect("len")) + }; + assert!((recovered_rate - expected).abs() < f64::EPSILON); + assert!(recovered_rate > collapsed_rate); +} + +#[test] +fn empty_or_invalid_interval_payloads_fail_closed() { + assert_eq!( + EventInterval::new(10, 10), + Err(SubeventContainmentError::InvalidIntervalPayload) + ); + assert_eq!( + EventInterval::new(10, 9), + Err(SubeventContainmentError::InvalidIntervalPayload) + ); + assert_eq!( + containment_recovery_rate(&[], &[]), + Err(SubeventContainmentError::InvalidIntervalPayload) + ); + assert_eq!( + containment_recovery_rate(&[true], &[]), + Err(SubeventContainmentError::InvalidIntervalPayload) + ); + assert_eq!( + containment_recovery_rate(&[true, false], &[true]), + Err(SubeventContainmentError::InvalidIntervalPayload) + ); +} diff --git a/crates/subevent_containment/tests/crate_contract.rs b/crates/subevent_containment/tests/crate_contract.rs new file mode 100644 index 000000000..4c150b475 --- /dev/null +++ b/crates/subevent_containment/tests/crate_contract.rs @@ -0,0 +1,7 @@ +//! Integration contract for the `subevent_containment` package identity. + +#[test] +fn package_identity_is_stable() { + let observed = std::hint::black_box(env!("CARGO_PKG_NAME")); + assert_eq!(observed, "subevent_containment"); +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index c29d97433..9bdcd19c0 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -13,7 +13,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | six distinct clocks and uncertain intervals | PRD; ADR 0002 | PR #8 `temporal_core` on protected main; PR #5 historical only | implemented-main | | Allen relation algebra/bounded closure | ADR 0002; temporal research | PR #9 `temporal_core` path-consistency on protected main | implemented-main | | forward-only transition subgraph | PRD; ADR 0002/0003 | `relation_graph` on protected main | implemented-main | -| event ontology/evidence mentions | PRD; ADR 0003 | `event_core` mention/instance separation on protected main; `persistence_postgres` mention SQL implemented-main refuses mention-as-instance; event-instance SQL (#39 implemented-main) refuses inverted windows; full intelligence stack remaining | partial | +| event ontology/evidence mentions | PRD; ADR 0003 | `event_core` mention/instance separation on protected main; `subevent_containment` parent-window gate on the active PR; event-instance SQL implemented-main; full intelligence stack remaining | active-PR | | time-varying cross-classified multiple membership | PRD; ADR 0003 | `membership_core` network on protected main; multilevel estimators remaining | partial | | leakage-safe availability/cutoff snapshots | PRD; ADR 0002/0013 | `corpus_split` on protected main | implemented-main | | recovery metrics (RMSE, bias, coverage, graph, temporal order, Monte Carlo SE gates) | PRD; Test Strategy; ADR 0007/0014 | `validation_core` on protected main (PR #19); SE-aware Monte Carlo gates included | implemented-main | diff --git a/docs/adr/0003-relational-event-multiple-membership.md b/docs/adr/0003-relational-event-multiple-membership.md index c5b1a154c..90da78bc4 100644 --- a/docs/adr/0003-relational-event-multiple-membership.md +++ b/docs/adr/0003-relational-event-multiple-membership.md @@ -1,7 +1,7 @@ # ADR 0003 — Relational event ontology and time-varying multiple membership **Decision status:** Accepted -**Implementation maturity:** partial — membership network and event mention/instance separation implemented-main; typed relation graph with forward-only transitions active-PR; multilevel estimators and persistence remain accepted-target +**Implementation maturity:** active-PR — subevent parent-window containment in `subevent_containment` on the active PR; multilevel estimators remain accepted-target **Date:** 2026-08-05 **Supersedes:** None. ADR 0016 owns TDT/CHRONOS event-intelligence task semantics; this ADR remains authoritative for ontology, relation, role, and membership structure. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1a9a7b315..fe05e1ec2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -8,7 +8,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio |---|---|---|---|---| | [0001](0001-rust-first-modular-msa.md) | Rust-first numerical core and CPU `f64` reference | Accepted | partial | ADR 0011 owns cross-service/MSA authority; 0001 retains numerical/backend authority. | | [0002](0002-six-clock-temporal-semantics.md) | Six-clock temporal semantics and fail-closed historical leakage prevention | Accepted | active-PR | Unmerged PR #8 is the canonical Task 3 replacement implementing typed clocks/intervals against the current protected-main lineage; conflicted PR #5 is superseded lineage. Later graph/split enforcement remains target work. | -| [0003](0003-relational-event-multiple-membership.md) | Relational event ontology and time-varying cross-classified multiple membership | Accepted | partial | Weighted time-varying membership network/roles are active-PR (PR #12); full multilevel estimators, graph ontology, and persistence remain accepted-target. ADR 0016 owns event-intelligence tasks. | +| [0003](0003-relational-event-multiple-membership.md) | Relational event ontology and time-varying cross-classified multiple membership | Accepted | active-PR | Subevent parent-window containment in `subevent_containment` on the active PR; multilevel estimators remain accepted-target. | | [0004](0004-shared-multilingual-latent-space.md) | One shared multilingual latent space with explicit invariance status | Accepted | accepted-target | ADR 0012 owns the full topic-estimator/backend/global-topic contract. | | [0005](0005-posterior-esem-dsem.md) | Posterior-aware ESEM/DSEM and valid compositional coordinates | Accepted | accepted-target | Downstream psychometric authority; upstream topic/network model is clarified by ADR 0012. | | [0006](0006-vram-gpu-nvidia-orchestration.md) | VRAM-adaptive GPU compute and model-credential boundary | Accepted | accepted-target | LLM orchestration policy superseded by ADR 0010; autonomous development authority governed by ADR 0015. | diff --git a/docs/research/standards-and-literature.md b/docs/research/standards-and-literature.md index b4b144684..6349128af 100644 --- a/docs/research/standards-and-literature.md +++ b/docs/research/standards-and-literature.md @@ -96,6 +96,8 @@ Moreau, L., & Missier, P. (Eds.). (2013). *PROV-DM: The PROV data model*. World TEPP separates stable record identity, content equality, exact text location, wire representation, authorization, and provenance. JSON wire records are explicit versioned DTOs with unknown-field rejection and reconstruct through domain validation. `SHA-256` detects content substitution but is not treated as proof of origin, authority, or chain of custody. +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. *Communications of the ACM, 26*(11), 832–843. https://doi.org/10.1145/182.358434. The `during` relation informs `subevent_containment`; the crate does not implement Allen composition. + ## AI risk, management systems, and assurance readiness International Organization for Standardization. (2023a). *Information technology—Artificial intelligence—Guidance on risk management* (ISO/IEC Standard No. 23894:2023). https://www.iso.org/standard/77304.html diff --git a/docs/research/subevent-containment.md b/docs/research/subevent-containment.md new file mode 100644 index 000000000..dc882dc00 --- /dev/null +++ b/docs/research/subevent-containment.md @@ -0,0 +1,29 @@ +# Subevent parent-window containment (doctoring) + +## Scope + +`subevent_containment` requires a half-open subevent interval to lie inside +its parent event-time interval. Recovery is the computed share of +containment flags that match known truth. + +This slice does not persist subevents, promote mentions to instances, or +implement Allen composition. + +## Authority + +### Normative TEPP contract + +- `docs/adr/0003-relational-event-multiple-membership.md` — event instances + own subevents, roles, and provenance; mentions remain fallible evidence. +- `docs/adr/0002-six-clock-temporal-semantics.md` — event/valid time is the + clock for occurrence intervals. + +### Supporting literature + +Allen (1983) includes the `during` relation. Containment here is the +half-open special case used to refuse escaped subevents; the crate does +not implement the full Allen table. + +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/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 295fbae0f..3706e6f75 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 | +| Subevent parent containment | `subevent_containment` | active-PR | this PR | containment-flag recovery vs accept-all | ADR 0003 | | 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..5c1b659bc 100644 --- a/scripts/check_workspace_contract.py +++ b/scripts/check_workspace_contract.py @@ -23,6 +23,7 @@ "tepp_simulation", "validation_core", "tepp_api", + "subevent_containment", ) REQUIRED_CI_SNIPPETS: tuple[str, ...] = (