diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..e27e4892f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- `tepp_api` keeps `AnalysisRunRequest` / `AnalysisRunAccepted` metric-free and allows `tepp.scientific_acceptance.v1` only on a succeeded terminal result with output profile `scientific_acceptance_v1`. Receipt RMSE/bias/coverage/SE-gate keys (including both standard errors, Wilson upper, and temporal-order accuracy), a scientific-acceptance profile without the artifact, a failed terminal carrying the artifact, an all-zero or run-id-mismatched binding digest, a model that does not match the request, a future or malformed cutoff, negative RMSE/SEs, out-of-range coverage, inverted Wilson bounds, and an `se_gate_accepted` flag inconsistent with `|RMSE| ≤ k · SE(RMSE)` fail closed. This is the GAP-003A API wire slice for issue #166; `analysis_engine` library binding stays on live #356; persistence remains GAP-003B. + - `event_core` adds bounded Allen interval-consistency classification, atomic path-consistency closure, contradiction/resource refusals, and an explicit dependency-error fallback without claiming unrestricted global satisfiability. - `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 2, p. 12 `MANIFESTTRAITVAR`; §7.1, p. 19; p. 16 `MANIFESTTRAITVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-27T14:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised manifest-trait variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 83–84). Table 2 names `MANIFESTTRAITVAR` `Ψ_τ` the additional time-invariant variance-covariance on the measurement level and sets it `NULL` when there is no manifest trait. Equation 5 writes `Γ ~ N(τ, Ψ)` and names that covariance the manifest traits. Section 7.1 names manifest traits stable individual differences in indicator levels, distinct from process-level `TRAITVAR` `φ_ξ`. Page 16 prints standardised matrices with the suffix `std` when appropriate. The printed example on p. 16 is `discreteDRIFTstd`, not `MANIFESTTRAITVARstd`. Footnote 4 standardises using only the relevant variance, not the total. The relevant variance for that named indicator-level correlation is `MANIFESTTRAITVAR`, not process-level `TRAITVAR` and not residual `MANIFESTVAR` `θ`. The 2017-era source forms `MANIFESTTRAITVARstd` only when `MANIFESTTRAITVAR != 0`, as `solve(sqrt(diag(MANIFESTTRAITVAR) + ridging)) %&% MANIFESTTRAITVAR` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. Unlike `TRAITVARstd`, that formation adds `diag(c(ridging), n.manifest)`. The default `ridging = FALSE` adds 0, not `0.0001`; that ridge is a numerical hack and is not this exact map. The scalar correlation is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR`. Form strictly positive `ψ` first, then `1 / √ψ`, then `(1 / √ψ) ψ (1 / √ψ)`. Unstandardised `MANIFESTTRAITVAR` is defined for a zero trait; standardised `MANIFESTTRAITVAR` is not. Zero `MANIFESTTRAITVAR` skips forming `MANIFESTTRAITVARstd` in the 2017-era source and fails closed here. Indicator-level trait variance is an event-time structural quantity, so a non-event clock fails closed. `MANIFESTTRAITVAR` does not require stable `a < 0`. Distinct positive `ψ` recover the same 1. `trait / trait = 1` is `TRAITVARstd` and recovers the same number and remains a distinct named quantity. `θ` is `MANIFESTVAR` and is measurement error, not this correlation. Meredith (1993) remains unread (web search 2026-08-27T14:20Z: Springer/Cambridge Core paywalled; Unpaywall historically `is_oa: false`; Springer `content/pdf` is an HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. diff --git a/crates/tepp_api/src/analysis_result.rs b/crates/tepp_api/src/analysis_result.rs index 61bd13db3..9d1e7fb93 100644 --- a/crates/tepp_api/src/analysis_result.rs +++ b/crates/tepp_api/src/analysis_result.rs @@ -6,6 +6,10 @@ //! redacted failure code. use crate::analysis_run::require_rfc3339_knowledge_cutoff; +use crate::scientific_acceptance::{ + SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE, SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION, + ScientificAcceptanceArtifact, +}; use crate::wire::{ from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, }; @@ -86,7 +90,8 @@ impl AnalysisResultSummary { /// The succeeded shape excludes source text, credentials, direct identity, /// respondent/item records, and unrestricted model output. The failed shape /// contains no measurement artifact. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[allow(clippy::derive_partial_eq_without_eq)] #[serde(deny_unknown_fields)] pub struct AnalysisRunTerminalResult { /// Semantic contract version. @@ -119,6 +124,10 @@ pub struct AnalysisRunTerminalResult { pub summary: Option, /// Stable snake-case code for a failed run. pub failure_code: Option, + /// Scientific-acceptance artifact, present only on a succeeded + /// `scientific_acceptance_v1` terminal result. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scientific_acceptance: Option, } impl AnalysisRunTerminalResult { @@ -153,6 +162,51 @@ impl AnalysisRunTerminalResult { completed_at: completed_at.into(), summary: Some(summary), failure_code: None, + scientific_acceptance: None, + }; + value.validate()?; + require_terminal_binding(request, accepted, &value)?; + Ok(value) + } + + /// Construct a succeeded terminal result that carries scientific acceptance. + /// + /// The request and accepted receipt remain metric-free. The artifact digest + /// becomes `result_sha256` and the schema is `tepp.scientific_acceptance.v1`. + /// + /// # Errors + /// + /// Returns a fail-closed error for invalid shape, digest, time, summary, + /// artifact, or request/receipt binding. + pub fn succeeded_scientific_acceptance( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + result_artifact_id: impl Into, + completed_at: impl Into, + summary: AnalysisResultSummary, + artifact: ScientificAcceptanceArtifact, + ) -> Result { + if request.output_profile != SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE { + return Err(ApiError::InvalidWirePayload); + } + let digest = artifact.sha256()?; + let value = Self { + contract_version: ANALYSIS_RESULT_CONTRACT_VERSION, + run_id: accepted.run_id.clone(), + run_state: AnalysisRunTerminalState::Succeeded, + idempotency_key: request.idempotency_key.clone(), + tenant_workspace_id: request.tenant_workspace_id.clone(), + snapshot_id: request.snapshot_id.clone(), + knowledge_cutoff: request.knowledge_cutoff.clone(), + model_contract_version: request.model_contract_version.clone(), + output_profile: request.output_profile.clone(), + result_artifact_id: Some(result_artifact_id.into()), + result_sha256: Some(digest), + result_schema_version: Some(SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION.into()), + completed_at: completed_at.into(), + summary: Some(summary), + failure_code: None, + scientific_acceptance: Some(artifact), }; value.validate()?; require_terminal_binding(request, accepted, &value)?; @@ -187,6 +241,7 @@ impl AnalysisRunTerminalResult { completed_at: completed_at.into(), summary: None, failure_code: Some(failure_code.into()), + scientific_acceptance: None, }; value.validate()?; require_terminal_binding(request, accepted, &value)?; @@ -270,7 +325,32 @@ impl AnalysisRunTerminalResult { if self.failure_code.is_some() { return Err(ApiError::InvalidWirePayload); } - Ok(()) + self.validate_scientific_acceptance(digest, schema) + } + + fn validate_scientific_acceptance(&self, digest: &str, schema: &str) -> Result<(), ApiError> { + match ( + self.output_profile.as_str() == SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE, + self.scientific_acceptance.as_ref(), + ) { + (false, None) => Ok(()), + (false, Some(_)) | (true, None) => Err(ApiError::InvalidWirePayload), + (true, Some(artifact)) => { + artifact.validate()?; + if schema != SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION + || artifact.schema_version != SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION + || artifact.run_id != self.run_id + || artifact.snapshot_id != self.snapshot_id + || artifact.knowledge_cutoff != self.knowledge_cutoff + || artifact.model != self.model_contract_version + || artifact.output_profile != self.output_profile + || artifact.sha256()?.as_str() != digest + { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } + } } fn validate_failed(&self) -> Result<(), ApiError> { @@ -278,6 +358,7 @@ impl AnalysisRunTerminalResult { || self.result_sha256.is_some() || self.result_schema_version.is_some() || self.summary.is_some() + || self.scientific_acceptance.is_some() { return Err(ApiError::InvalidWirePayload); } @@ -289,6 +370,8 @@ impl AnalysisRunTerminalResult { } } +impl Eq for AnalysisRunTerminalResult {} + /// Return whether a terminal result exactly binds to its submitted request. #[must_use] pub fn terminal_result_matches_request( diff --git a/crates/tepp_api/src/analysis_run.rs b/crates/tepp_api/src/analysis_run.rs index 97af85784..6f44076dd 100644 --- a/crates/tepp_api/src/analysis_run.rs +++ b/crates/tepp_api/src/analysis_run.rs @@ -1,6 +1,7 @@ //! Versioned analysis-run request and accepted-run response contracts. use crate::ApiError; +use crate::scientific_acceptance::refuse_metrics_on_receipt; use crate::wire::{ from_json, require_byte_limit, require_contract_version, require_nonempty, to_json, }; @@ -70,7 +71,8 @@ pub enum AnalysisRunStatusState { } /// Typed status/read response for an accepted analysis run. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[allow(clippy::derive_partial_eq_without_eq)] #[serde(deny_unknown_fields)] pub struct AnalysisRunStatus { /// Semantic contract version for this status payload family. @@ -102,6 +104,7 @@ impl AnalysisRunRequest { /// Returns wire, version, limit, or field-validation errors. pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { require_byte_limit(payload, maximum_bytes)?; + refuse_metrics_on_receipt(payload)?; let request: Self = from_json(payload)?; request.validate()?; Ok(request) @@ -184,6 +187,7 @@ impl AnalysisRunAccepted { /// Returns wire, version, limit, or field-validation errors. pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { require_byte_limit(payload, maximum_bytes)?; + refuse_metrics_on_receipt(payload)?; let accepted: Self = from_json(payload)?; accepted.validate()?; Ok(accepted) @@ -337,6 +341,8 @@ impl AnalysisRunStatus { } } +impl Eq for AnalysisRunStatus {} + /// Compare two requests for idempotent-retry semantic equality. #[must_use] pub fn requests_are_idempotent_matches( diff --git a/crates/tepp_api/src/lib.rs b/crates/tepp_api/src/lib.rs index 876703ebc..f12386e55 100644 --- a/crates/tepp_api/src/lib.rs +++ b/crates/tepp_api/src/lib.rs @@ -5,7 +5,9 @@ //! These pure wire contracts let TEPP operate standalone and as a modular CWL //! component without sharing application tables. Domain estimation remains in //! scientific crates; this crate only defines fail-closed interchange shapes. -//! Naruon and `LineageWeave` use the versioned analysis-run contract; `LineageWeave` +//! Analysis-run request and accepted receipts stay metric-free; only a succeeded +//! terminal result with profile `scientific_acceptance_v1` may carry +//! `tepp.scientific_acceptance.v1`. Naruon and `LineageWeave` use the versioned analysis-run contract; `LineageWeave` //! may also request a cutoff-safe project-history projection from explicit //! source evidence. Naruon owns the current purpose-bound export adapter. //! Loopback listeners prove the HTTP boundary without claiming production TLS, @@ -30,6 +32,7 @@ mod orchestration; mod project_history; mod project_journey; mod provider_payload; +mod scientific_acceptance; mod temporal_context; mod wire; @@ -95,6 +98,26 @@ pub use export::GraphMlExport; pub use export::JsonLdExport; /// Reproducibility manifest. pub use export::ReproducibilityManifest; +/// Default maximum scientific-acceptance artifact size. +pub use scientific_acceptance::DEFAULT_SCIENTIFIC_ACCEPTANCE_BYTE_LIMIT; +/// Bound compute backend identity for scientific acceptance. +pub use scientific_acceptance::SCIENTIFIC_ACCEPTANCE_BACKEND; +/// CPU `f64` reference model identity for scientific acceptance. +pub use scientific_acceptance::SCIENTIFIC_ACCEPTANCE_MODEL; +/// Output profile that authorizes a scientific-acceptance terminal artifact. +pub use scientific_acceptance::SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE; +/// Numeric precision bound into a scientific-acceptance artifact. +pub use scientific_acceptance::SCIENTIFIC_ACCEPTANCE_PRECISION; +/// Versioned scientific-acceptance artifact schema. +pub use scientific_acceptance::SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION; +/// Digest-bound scientific-acceptance artifact. +pub use scientific_acceptance::ScientificAcceptanceArtifact; +/// Nested recovery report carried by a scientific-acceptance artifact. +pub use scientific_acceptance::ScientificAcceptanceReport; +/// Detect scientific-metric keys on a receipt JSON object. +pub use scientific_acceptance::receipt_json_carries_scientific_metrics; +/// Refuse a request or accepted receipt that already carries metrics. +pub use scientific_acceptance::refuse_metrics_on_receipt; /// Analytical export purpose. pub use authorization::AnalyticalPurpose; diff --git a/crates/tepp_api/src/scientific_acceptance.rs b/crates/tepp_api/src/scientific_acceptance.rs new file mode 100644 index 000000000..4fb00f1ab --- /dev/null +++ b/crates/tepp_api/src/scientific_acceptance.rs @@ -0,0 +1,487 @@ +//! Operator-usable `tepp.scientific_acceptance.v1` terminal-result wire. +//! +//! GAP-003A second slice: `AnalysisRunRequest` and `AnalysisRunAccepted` stay +//! metric-free receipts. Only a succeeded terminal result with output profile +//! `scientific_acceptance_v1` may carry the scientific-acceptance artifact. +//! Persistence and Compose recovery remain GAP-003B. + +use crate::ApiError; +use crate::analysis_run::require_rfc3339_knowledge_cutoff; +use crate::wire::{from_json, require_byte_limit, require_nonempty, to_json_with_limit}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Versioned scientific-acceptance artifact schema. +pub const SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION: &str = "tepp.scientific_acceptance.v1"; +/// Output profile that authorizes this artifact on a terminal result. +pub const SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE: &str = "scientific_acceptance_v1"; +/// CPU `f64` reference model identity for scientific acceptance. +pub const SCIENTIFIC_ACCEPTANCE_MODEL: &str = "validation_cpu_f64_v1"; +/// Backend identity bound into the artifact. +pub const SCIENTIFIC_ACCEPTANCE_BACKEND: &str = "cpu"; +/// Numeric precision bound into the artifact. +pub const SCIENTIFIC_ACCEPTANCE_PRECISION: &str = "f64"; +/// Default maximum serialized artifact size. +pub const DEFAULT_SCIENTIFIC_ACCEPTANCE_BYTE_LIMIT: usize = 16 * 1024; +/// Prefix of the hash-stable durable run identity echoed on the artifact. +pub const SCIENTIFIC_ACCEPTANCE_RUN_ID_PREFIX: &str = "tepp-validation-"; +/// Hex characters taken from the binding digest for `run_id`. +pub const SCIENTIFIC_ACCEPTANCE_RUN_ID_HEX_LEN: usize = 32; +/// Largest finite SE-gate multiplier accepted on this wire. +pub const MAX_SE_GATE_K: f64 = 8.0; + +const FORBIDDEN_RECEIPT_KEYS: [&str; 12] = [ + "rmse", + "rmse_standard_error", + "mean_bias", + "bias_standard_error", + "interval_coverage", + "coverage_wilson_lower", + "coverage_wilson_upper", + "temporal_order_accuracy", + "se_gate_accepted", + "se_gate_k", + "scientific_acceptance", + "report", +]; + +/// Nested recovery report carried by the terminal artifact. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[allow(clippy::derive_partial_eq_without_eq)] +#[serde(deny_unknown_fields)] +pub struct ScientificAcceptanceReport { + /// Study label (not free-form PII). + pub study_label: String, + /// Root-mean-square error. + pub rmse: f64, + /// RMSE standard error. + pub rmse_standard_error: f64, + /// Mean signed bias. + pub mean_bias: f64, + /// Bias standard error. + pub bias_standard_error: f64, + /// Empirical interval coverage. + pub interval_coverage: f64, + /// Wilson lower bound for coverage. + pub coverage_wilson_lower: f64, + /// Wilson upper bound for coverage. + pub coverage_wilson_upper: f64, + /// Temporal-order accuracy. + pub temporal_order_accuracy: f64, +} + +impl ScientificAcceptanceReport { + fn validate(&self) -> Result<(), ApiError> { + require_nonempty(&self.study_label)?; + if self.study_label.trim() != self.study_label { + return Err(ApiError::InvalidWirePayload); + } + for value in [ + self.rmse, + self.rmse_standard_error, + self.mean_bias, + self.bias_standard_error, + self.interval_coverage, + self.coverage_wilson_lower, + self.coverage_wilson_upper, + self.temporal_order_accuracy, + ] { + if !value.is_finite() { + return Err(ApiError::InvalidWirePayload); + } + } + if self.rmse < 0.0 || self.rmse_standard_error < 0.0 || self.bias_standard_error < 0.0 { + return Err(ApiError::InvalidWirePayload); + } + for unit_interval in [ + self.interval_coverage, + self.coverage_wilson_lower, + self.coverage_wilson_upper, + self.temporal_order_accuracy, + ] { + if !(0.0..=1.0).contains(&unit_interval) { + return Err(ApiError::InvalidWirePayload); + } + } + if self.coverage_wilson_lower > self.interval_coverage + || self.interval_coverage > self.coverage_wilson_upper + { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } +} + +/// Digest-bound scientific-acceptance artifact for one completed run. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[allow(clippy::derive_partial_eq_without_eq)] +#[serde(deny_unknown_fields)] +pub struct ScientificAcceptanceArtifact { + /// Versioned artifact schema. + pub schema_version: String, + /// Durable run identity echoed from the receipt. + pub run_id: String, + /// Canonical lowercase SHA-256 of the run binding. + pub binding_sha256: String, + /// Immutable source snapshot identity. + pub snapshot_id: String, + /// Historical cutoff applied to availability. + pub knowledge_cutoff: String, + /// Bound model identity. + pub model: String, + /// Bound numeric seed. + pub seed: u64, + /// Bound compute backend. + pub backend: String, + /// Bound numeric precision. + pub precision: String, + /// Output profile that selected this executor. + pub output_profile: String, + /// Number of cutoff-eligible evidence identities. + pub eligible_evidence_count: u64, + /// Whether RMSE toward 0 passed the SE-aware gate. + pub se_gate_accepted: bool, + /// SE-gate multiplier used for acceptance. + pub se_gate_k: f64, + /// Machine-readable recovery report. + pub report: ScientificAcceptanceReport, +} + +impl ScientificAcceptanceArtifact { + /// Parse and validate an artifact with the default payload limit. + /// + /// # Errors + /// + /// Returns a fail-closed wire, identity, digest, or numeric error. + pub fn from_json(payload: &str) -> Result { + Self::from_json_with_limit(payload, DEFAULT_SCIENTIFIC_ACCEPTANCE_BYTE_LIMIT) + } + + /// Parse and validate an artifact with a caller-supplied payload limit. + /// + /// # Errors + /// + /// Returns a fail-closed wire, identity, digest, limit, or numeric error. + pub fn from_json_with_limit(payload: &str, maximum_bytes: usize) -> Result { + require_byte_limit(payload, maximum_bytes)?; + let value: Self = from_json(payload)?; + value.validate()?; + Ok(value) + } + + /// Serialize this validated artifact. + /// + /// # Errors + /// + /// Returns a fail-closed validation or serialization error. + pub fn to_json(&self) -> Result { + self.validate()?; + to_json_with_limit(self, DEFAULT_SCIENTIFIC_ACCEPTANCE_BYTE_LIMIT) + } + + /// Canonical lowercase SHA-256 of the validated artifact JSON. + /// + /// # Errors + /// + /// Returns the same failures as [`Self::to_json`]. + pub fn sha256(&self) -> Result { + let json = self.to_json()?; + Ok(encode_hex(&Sha256::digest(json.as_bytes()))) + } + + pub(crate) fn validate(&self) -> Result<(), ApiError> { + if self.schema_version != SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION + || self.output_profile != SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE + || self.model != SCIENTIFIC_ACCEPTANCE_MODEL + || self.backend != SCIENTIFIC_ACCEPTANCE_BACKEND + || self.precision != SCIENTIFIC_ACCEPTANCE_PRECISION + || self.eligible_evidence_count == 0 + || !self.se_gate_k.is_finite() + || self.se_gate_k < 0.0 + || self.se_gate_k > MAX_SE_GATE_K + { + return Err(ApiError::InvalidWirePayload); + } + require_nonempty(&self.run_id)?; + require_nonempty(&self.snapshot_id)?; + require_rfc3339_knowledge_cutoff(&self.knowledge_cutoff)?; + if !is_canonical_sha256(&self.binding_sha256) + || self.binding_sha256.bytes().all(|byte| byte == b'0') + { + return Err(ApiError::InvalidWirePayload); + } + if self.run_id.len() + != SCIENTIFIC_ACCEPTANCE_RUN_ID_PREFIX.len() + SCIENTIFIC_ACCEPTANCE_RUN_ID_HEX_LEN + || !self.run_id.starts_with(SCIENTIFIC_ACCEPTANCE_RUN_ID_PREFIX) + || self.run_id.as_bytes()[SCIENTIFIC_ACCEPTANCE_RUN_ID_PREFIX.len()..] + != self.binding_sha256.as_bytes()[..SCIENTIFIC_ACCEPTANCE_RUN_ID_HEX_LEN] + { + return Err(ApiError::InvalidWirePayload); + } + self.report.validate()?; + let expected_gate = se_gate_accepts( + self.report.rmse, + self.report.rmse_standard_error, + self.se_gate_k, + )?; + if self.se_gate_accepted != expected_gate { + return Err(ApiError::InvalidWirePayload); + } + Ok(()) + } +} + +impl Eq for ScientificAcceptanceReport {} + +impl Eq for ScientificAcceptanceArtifact {} + +/// Return whether a receipt JSON object carries scientific-metric keys. +/// +/// Request and accepted receipts must remain metric-free. Unknown-field denial +/// is the wire gate; this helper names the forbidden keys for operators. +#[must_use] +pub fn receipt_json_carries_scientific_metrics(payload: &str) -> bool { + let Ok(value) = serde_json::from_str::(payload) else { + return false; + }; + let Some(object) = value.as_object() else { + return false; + }; + FORBIDDEN_RECEIPT_KEYS + .iter() + .any(|key| object.contains_key(*key)) +} + +/// Refuse a request or accepted receipt that already carries metrics. +/// +/// # Errors +/// +/// Returns [`ApiError::InvalidWirePayload`] when a forbidden metric key is +/// present on a receipt object. +pub fn refuse_metrics_on_receipt(payload: &str) -> Result<(), ApiError> { + if receipt_json_carries_scientific_metrics(payload) { + Err(ApiError::InvalidWirePayload) + } else { + Ok(()) + } +} + +fn is_canonical_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn se_gate_accepts(rmse: f64, rmse_standard_error: f64, k: f64) -> Result { + if ![rmse, rmse_standard_error, k] + .iter() + .all(|value| value.is_finite()) + { + return Err(ApiError::InvalidWirePayload); + } + if k < 0.0 || rmse_standard_error < 0.0 || rmse < 0.0 { + return Err(ApiError::InvalidWirePayload); + } + if rmse_standard_error == 0.0 { + return Ok(rmse == 0.0); + } + let scale = rmse.max(rmse_standard_error).max(1.0); + Ok((rmse / scale) <= k * (rmse_standard_error / scale)) +} + +fn encode_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::{ + DEFAULT_SCIENTIFIC_ACCEPTANCE_BYTE_LIMIT, SCIENTIFIC_ACCEPTANCE_BACKEND, + SCIENTIFIC_ACCEPTANCE_MODEL, SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE, + SCIENTIFIC_ACCEPTANCE_PRECISION, SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION, + ScientificAcceptanceArtifact, ScientificAcceptanceReport, encode_hex, is_canonical_sha256, + receipt_json_carries_scientific_metrics, refuse_metrics_on_receipt, + }; + use crate::ApiError; + + fn report() -> ScientificAcceptanceReport { + ScientificAcceptanceReport { + study_label: "gap-003a-terminal".into(), + rmse: 0.02, + rmse_standard_error: 0.01, + mean_bias: 0.0, + bias_standard_error: 0.02, + interval_coverage: 0.95, + coverage_wilson_lower: 0.90, + coverage_wilson_upper: 0.98, + temporal_order_accuracy: 1.0, + } + } + + fn artifact() -> ScientificAcceptanceArtifact { + ScientificAcceptanceArtifact { + schema_version: SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION.into(), + run_id: "tepp-validation-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + binding_sha256: "a".repeat(64), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model: SCIENTIFIC_ACCEPTANCE_MODEL.into(), + seed: 7, + backend: SCIENTIFIC_ACCEPTANCE_BACKEND.into(), + precision: SCIENTIFIC_ACCEPTANCE_PRECISION.into(), + output_profile: SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE.into(), + eligible_evidence_count: 4, + se_gate_accepted: true, + se_gate_k: 3.0, + report: report(), + } + } + + #[test] + fn artifact_round_trips_and_rejects_hostile_payloads() { + let value = artifact(); + let json = value.to_json().expect("json"); + assert_eq!( + ScientificAcceptanceArtifact::from_json(&json).expect("decode"), + value + ); + assert_eq!(value.sha256().expect("digest").len(), 64); + assert!(json.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION)); + + let unknown = json.replacen('{', r#"{"extra":true,"#, 1); + assert_eq!( + ScientificAcceptanceArtifact::from_json(&unknown), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + ScientificAcceptanceArtifact::from_json_with_limit(&json, 8), + Err(ApiError::LimitExceeded) + ); + assert!(DEFAULT_SCIENTIFIC_ACCEPTANCE_BYTE_LIMIT >= json.len()); + assert_eq!( + ScientificAcceptanceArtifact::from_json("not-json"), + Err(ApiError::InvalidWirePayload) + ); + } + + #[test] + fn identities_metrics_and_empty_evidence_fail_closed() { + let mut zero = artifact(); + zero.binding_sha256 = "0".repeat(64); + assert_eq!(zero.to_json(), Err(ApiError::InvalidWirePayload)); + let mut empty = artifact(); + empty.eligible_evidence_count = 0; + assert_eq!(empty.to_json(), Err(ApiError::InvalidWirePayload)); + let mut schema = artifact(); + schema.schema_version = "tepp.scientific_acceptance.v0".into(); + assert_eq!(schema.to_json(), Err(ApiError::InvalidWirePayload)); + let mut profile = artifact(); + profile.output_profile = "validation-report".into(); + assert_eq!(profile.to_json(), Err(ApiError::InvalidWirePayload)); + let mut model = artifact(); + model.model = "other".into(); + assert_eq!(model.to_json(), Err(ApiError::InvalidWirePayload)); + let mut backend = artifact(); + backend.backend = "gpu".into(); + assert_eq!(backend.to_json(), Err(ApiError::InvalidWirePayload)); + let mut precision = artifact(); + precision.precision = "f32".into(); + assert_eq!(precision.to_json(), Err(ApiError::InvalidWirePayload)); + let mut gate = artifact(); + gate.se_gate_k = -1.0; + assert_eq!(gate.to_json(), Err(ApiError::InvalidWirePayload)); + let mut nan = artifact(); + nan.se_gate_k = f64::NAN; + assert_eq!(nan.to_json(), Err(ApiError::InvalidWirePayload)); + let mut padded = artifact(); + padded.report.study_label = " padded".into(); + assert_eq!(padded.to_json(), Err(ApiError::InvalidWirePayload)); + let mut blank = artifact(); + blank.report.study_label.clear(); + assert_eq!(blank.to_json(), Err(ApiError::InvalidWirePayload)); + let mut inf = artifact(); + inf.report.rmse = f64::INFINITY; + assert_eq!(inf.to_json(), Err(ApiError::InvalidWirePayload)); + let mut negative = artifact(); + negative.report.rmse = -0.01; + assert_eq!(negative.to_json(), Err(ApiError::InvalidWirePayload)); + let mut coverage = artifact(); + coverage.report.interval_coverage = 1.2; + assert_eq!(coverage.to_json(), Err(ApiError::InvalidWirePayload)); + let mut inverted = artifact(); + inverted.report.coverage_wilson_lower = 0.99; + inverted.report.interval_coverage = 0.95; + inverted.report.coverage_wilson_upper = 0.90; + assert_eq!(inverted.to_json(), Err(ApiError::InvalidWirePayload)); + let mut false_accept = artifact(); + false_accept.report.rmse = 10.0; + false_accept.se_gate_accepted = true; + assert_eq!(false_accept.to_json(), Err(ApiError::InvalidWirePayload)); + let mut false_reject = artifact(); + false_reject.se_gate_accepted = false; + assert_eq!(false_reject.to_json(), Err(ApiError::InvalidWirePayload)); + let mut future = artifact(); + future.knowledge_cutoff = "2099-01-01T00:00:00Z".into(); + assert_eq!(future.to_json(), Err(ApiError::InvalidWirePayload)); + let mut malformed_cutoff = artifact(); + malformed_cutoff.knowledge_cutoff = "not-a-time".into(); + assert_eq!( + malformed_cutoff.to_json(), + Err(ApiError::InvalidWirePayload) + ); + let mut foreign_run = artifact(); + foreign_run.run_id = "tepp-validation-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(); + assert_eq!(foreign_run.to_json(), Err(ApiError::InvalidWirePayload)); + let mut run = artifact(); + run.run_id.clear(); + assert_eq!(run.to_json(), Err(ApiError::InvalidWirePayload)); + let mut snap = artifact(); + snap.snapshot_id.clear(); + assert_eq!(snap.to_json(), Err(ApiError::InvalidWirePayload)); + let mut cutoff = artifact(); + cutoff.knowledge_cutoff.clear(); + assert_eq!(cutoff.to_json(), Err(ApiError::InvalidWirePayload)); + let mut mixed = artifact(); + mixed.binding_sha256 = "A".repeat(64); + assert_eq!(mixed.to_json(), Err(ApiError::InvalidWirePayload)); + let mut short = artifact(); + short.binding_sha256 = "aa".into(); + assert_eq!(short.to_json(), Err(ApiError::InvalidWirePayload)); + } + + #[test] + fn receipts_must_not_carry_scientific_metrics() { + assert!(!receipt_json_carries_scientific_metrics( + r#"{"contract_version":1,"run_id":"r","run_state":"accepted","idempotency_key":"i"}"# + )); + assert!(receipt_json_carries_scientific_metrics( + r#"{"rmse_standard_error":0.1}"# + )); + assert!(receipt_json_carries_scientific_metrics( + r#"{"temporal_order_accuracy":1.0}"# + )); + assert!(receipt_json_carries_scientific_metrics( + r#"{"scientific_acceptance":{}}"# + )); + assert_eq!( + refuse_metrics_on_receipt(r#"{"mean_bias":0.0}"#), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + refuse_metrics_on_receipt(r#"{"contract_version":1}"#), + Ok(()) + ); + assert!(!receipt_json_carries_scientific_metrics("[]")); + assert!(!receipt_json_carries_scientific_metrics("not-json")); + assert_eq!(encode_hex(&[0x0a, 0xff]), "0aff"); + assert!(is_canonical_sha256(&"ab".repeat(32))); + assert!(!is_canonical_sha256("zz")); + } +} diff --git a/crates/tepp_api/tests/scientific_acceptance_contract.rs b/crates/tepp_api/tests/scientific_acceptance_contract.rs new file mode 100644 index 000000000..b11ec0236 --- /dev/null +++ b/crates/tepp_api/tests/scientific_acceptance_contract.rs @@ -0,0 +1,224 @@ +//! GAP-003A: scientific acceptance is a terminal result, never a receipt. + +use tepp_api::{ + ANALYSIS_RUN_CONTRACT_VERSION, AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, + AnalysisRunStatus, AnalysisRunTerminalResult, ApiError, SCIENTIFIC_ACCEPTANCE_BACKEND, + SCIENTIFIC_ACCEPTANCE_MODEL, SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE, + SCIENTIFIC_ACCEPTANCE_PRECISION, SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION, + ScientificAcceptanceArtifact, ScientificAcceptanceReport, + receipt_json_carries_scientific_metrics, +}; + +fn report() -> ScientificAcceptanceReport { + ScientificAcceptanceReport { + study_label: "gap-003a-api".into(), + rmse: 0.02, + rmse_standard_error: 0.01, + mean_bias: 0.0, + bias_standard_error: 0.02, + interval_coverage: 0.95, + coverage_wilson_lower: 0.90, + coverage_wilson_upper: 0.98, + temporal_order_accuracy: 1.0, + } +} + +fn artifact() -> ScientificAcceptanceArtifact { + ScientificAcceptanceArtifact { + schema_version: SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION.into(), + run_id: "tepp-validation-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + binding_sha256: "a".repeat(64), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model: SCIENTIFIC_ACCEPTANCE_MODEL.into(), + seed: 7, + backend: SCIENTIFIC_ACCEPTANCE_BACKEND.into(), + precision: SCIENTIFIC_ACCEPTANCE_PRECISION.into(), + output_profile: SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE.into(), + eligible_evidence_count: 4, + se_gate_accepted: true, + se_gate_k: 3.0, + report: report(), + } +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: ANALYSIS_RUN_CONTRACT_VERSION, + idempotency_key: "idem-1".into(), + tenant_workspace_id: "tenant-ws-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: SCIENTIFIC_ACCEPTANCE_MODEL.into(), + output_profile: SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE.into(), + } +} + +fn accepted() -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "tepp-validation-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "accepted", + "idem-1", + ) + .expect("accepted") +} + +fn summary() -> AnalysisResultSummary { + AnalysisResultSummary::new("scientific_acceptance", 4, 8, "validated").expect("summary") +} + +#[test] +fn request_and_accepted_receipts_stay_metric_free() { + let request = request(); + let json = request.to_json().expect("request json"); + assert!(!receipt_json_carries_scientific_metrics(&json)); + assert!(!json.contains("rmse")); + assert_eq!( + AnalysisRunRequest::from_json(&json).expect("decode"), + request + ); + let with_rmse = json.replacen('{', r#"{"rmse":0.04,"#, 1); + assert_eq!( + AnalysisRunRequest::from_json(&with_rmse), + Err(ApiError::InvalidWirePayload) + ); + let with_artifact = json.replacen('{', r#"{"scientific_acceptance":{},"#, 1); + assert_eq!( + AnalysisRunRequest::from_json(&with_artifact), + Err(ApiError::InvalidWirePayload) + ); + + let accepted = accepted(); + let accepted_json = accepted.to_json().expect("accepted json"); + assert!(!receipt_json_carries_scientific_metrics(&accepted_json)); + let accepted_metrics = accepted_json.replacen('{', r#"{"report":{},"#, 1); + assert_eq!( + AnalysisRunAccepted::from_json(&accepted_metrics), + Err(ApiError::InvalidWirePayload) + ); + assert_eq!( + AnalysisRunStatus::accepted(&accepted) + .expect("status") + .terminal_result, + None + ); +} + +#[test] +fn only_succeeded_terminal_may_carry_scientific_acceptance() { + let terminal = AnalysisRunTerminalResult::succeeded_scientific_acceptance( + &request(), + &accepted(), + "artifact-1", + "2026-08-02T03:04:05Z", + summary(), + artifact(), + ) + .expect("terminal"); + assert_eq!( + terminal.result_schema_version.as_deref(), + Some(SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION) + ); + let json = terminal.to_json().expect("json"); + assert!(json.contains(SCIENTIFIC_ACCEPTANCE_SCHEMA_VERSION)); + assert_eq!( + AnalysisRunTerminalResult::from_json(&json).expect("round-trip"), + terminal + ); + let status = + AnalysisRunStatus::terminal(&request(), &accepted(), terminal.clone()).expect("status"); + assert!(status.terminal_result.is_some()); + + let mut mismatched = artifact(); + mismatched.run_id = "tepp-validation-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(); + mismatched.binding_sha256 = "b".repeat(64); + assert_eq!( + AnalysisRunTerminalResult::succeeded_scientific_acceptance( + &request(), + &accepted(), + "artifact-1", + "2026-08-02T03:04:05Z", + summary(), + mismatched, + ), + Err(ApiError::InvalidWirePayload) + ); + + let mut wrong_model = request(); + wrong_model.model_contract_version = "other-model".into(); + assert_eq!( + AnalysisRunTerminalResult::succeeded_scientific_acceptance( + &wrong_model, + &accepted(), + "artifact-1", + "2026-08-02T03:04:05Z", + summary(), + artifact(), + ), + Err(ApiError::InvalidWirePayload) + ); + + let mut ordinary = request(); + ordinary.output_profile = "validation-report".into(); + assert_eq!( + AnalysisRunTerminalResult::succeeded_scientific_acceptance( + &ordinary, + &accepted(), + "artifact-1", + "2026-08-02T03:04:05Z", + summary(), + artifact(), + ), + Err(ApiError::InvalidWirePayload) + ); + + let failed = AnalysisRunTerminalResult::failed( + &request(), + &accepted(), + "2026-08-02T03:04:05Z", + "estimation_failed", + ) + .expect("failed"); + assert!(failed.scientific_acceptance.is_none()); + let mut failed_with_artifact = failed.clone(); + failed_with_artifact.scientific_acceptance = Some(artifact()); + assert_eq!( + failed_with_artifact.to_json(), + Err(ApiError::InvalidWirePayload) + ); + + let mut tampered = terminal; + tampered.result_sha256 = Some("b".repeat(64)); + assert_eq!(tampered.to_json(), Err(ApiError::InvalidWirePayload)); +} + +#[test] +fn scientific_acceptance_profile_without_artifact_fails_closed() { + let mut profiled = request(); + profiled.output_profile = SCIENTIFIC_ACCEPTANCE_OUTPUT_PROFILE.into(); + let ordinary = AnalysisRunTerminalResult::succeeded( + &profiled, + &accepted(), + "artifact-1", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "tepp-result-v1", + "2026-08-02T03:04:05Z", + summary(), + ); + assert_eq!(ordinary, Err(ApiError::InvalidWirePayload)); + + let mut other_profile = request(); + other_profile.output_profile = "validation-report".into(); + let mut stuffed = AnalysisRunTerminalResult::succeeded( + &other_profile, + &accepted(), + "artifact-1", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "tepp-result-v1", + "2026-08-02T03:04:05Z", + summary(), + ) + .expect("ordinary"); + stuffed.scientific_acceptance = Some(artifact()); + assert_eq!(stuffed.to_json(), Err(ApiError::InvalidWirePayload)); +} diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index b76b688e1..4c15fc107 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -1,6 +1,7 @@ # TEPP API and Modular Integration Contract **Status:** Accepted target contract; exact endpoints are introduced only with executable services. +**Last reviewed:** 2026-08-31 **Last reviewed:** 2026-08-24 **Last reviewed:** 2026-08-21 @@ -30,6 +31,7 @@ Current protected main exposes Rust library/domain contracts. The active stack a | project-history projection contract | `tepp_api` v1 wire DTOs | LineageWeave | active-PR | | analysis-run status/terminal-result contracts | `tepp_api` v1 wire DTOs | naruon, orchestrator, UI | active-PR #157 | | cutoff-safe analysis-run readiness execution | `analysis_engine` bounded Rust crate | `tepp_api`, future HTTP/service adapters | active-PR | +| scientific-acceptance terminal artifact v1 | TEPP `tepp_api` `tepp.scientific_acceptance.v1` | operators, naruon, future analysis-run adapters | active-PR | ## 3. Versioning diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..0ebf63825 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -40,6 +40,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | leakage-safe availability/cutoff snapshots | PRD; ADR 0002/0013 | `corpus_split` on protected main | implemented-main | | interval-aware historical eligibility (`available_time` fully ≤ cutoff) | ADR 0002 | `temporal_core` `evaluate_historical_eligibility` on the active PR; unknown/open-ended availability fails closed | active-PR | +| GAP-003A scientific-acceptance terminal wire | PRD; ADR 0013/0014; issue #166 | `tepp_api` metric-free analysis-run receipts; `tepp.scientific_acceptance.v1` only on succeeded terminal results on this PR with model/cutoff/metric-range/`k`-consistency gates; `analysis_engine` library binding remains live #356; persistence remains GAP-003B | active-PR | | 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 | | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), and backup/restore integrity revalidation (#44 implemented-main); remaining physical ERD constraints | partial | | PostgreSQL bitemporal/lineage persistence | ADR 0013; Architecture/ERD | `persistence_postgres` migration contracts, in-memory adapters, live SQL session/document SQL port, tenant RLS (`0002` + session GUC/role helpers), `DATABASE_URL` SQLx gate, optional `live-sqlx` `PgPool` driver, exact-head live PostgreSQL CI with isolation proof, append-only immutability triggers (`0004`), temporal interval ordering CHECKs (`0005`), typed membership assignment (`0006` implemented-main), event-relation/mention/instance SQL (#37–#39 implemented-main), source-artifact SQL (#40 implemented-main), audit-event SQL (#41 implemented-main), concurrent document-write stress (#43 implemented-main), backup/restore integrity revalidation (#44 implemented-main), entity/project target SQL (this PR); remaining physical ERD constraints | partial | diff --git a/docs/research/scientific-acceptance-terminal-wire.md b/docs/research/scientific-acceptance-terminal-wire.md new file mode 100644 index 000000000..db40bba42 --- /dev/null +++ b/docs/research/scientific-acceptance-terminal-wire.md @@ -0,0 +1,60 @@ +# Scientific-acceptance terminal wire (GAP-003A) + +## Scope + +This note doctors the second GAP-003A executable slice in `tepp_api` +(issue #166): + +1. `AnalysisRunRequest` and `AnalysisRunAccepted` remain metric-free receipts; +2. only a succeeded terminal result with output profile + `scientific_acceptance_v1` may carry `tepp.scientific_acceptance.v1`; +3. RMSE, bias, both standard errors, coverage, Wilson bounds, temporal-order, + SE-gate, and nested report keys on a receipt fail closed; +4. a scientific-acceptance profile without the artifact, a failed terminal with + the artifact, a digest mismatch, an all-zero or `run_id`-mismatched binding + digest, a model that does not match the request, a future or malformed + cutoff, negative RMSE/SEs, out-of-range coverage, inverted Wilson bounds, + or an `se_gate_accepted` flag inconsistent with `|RMSE| ≤ k · SE(RMSE)` + fail closed. + +`analysis_engine` library binding remains on live PR #356. PostgreSQL +persistence and Compose recovery remain GAP-003B. This slice does not promote +an ADR 0014 claim authority. + +## Authoritative sources + +National Academies of Sciences, Engineering, and Medicine. (2019). +*Reproducibility and replicability in science*. The National Academies Press. +https://doi.org/10.17226/25303 + +Peng, R. D. (2011). Reproducible research in computational science. +*Science, 334*(6060), 1226–1227. https://doi.org/10.1126/science.1213847 + +Wasserstein, R. L., & Lazar, N. A. (2016). The ASA statement on *p*-values: +Context, process, and purpose. *The American Statistician, 70*(2), 129–133. +https://doi.org/10.1080/00031305.2016.1154108 + +National Institute of Standards and Technology. (2015). *Secure Hash Standard +(SHS)* (FIPS PUB 180-4). https://doi.org/10.6028/NIST.FIPS.180-4 + +## Application + +Peng (2011) and the National Academies (2019) require computational +reproducibility to bind identities without treating a receipt as a scientific +claim. Wasserstein and Lazar (2016) refuse to treat a numeric threshold as +automatic authority, so TEPP keeps RMSE/bias/coverage off +`AnalysisRunRequest` / `AnalysisRunAccepted` and allows those fields only on a +digest-bound terminal artifact. FIPS 180-4 SHA-256 detects whether the artifact +bytes agree with `result_sha256` (National Institute of Standards and +Technology, 2015). + +## Verification + +- metric keys on request or accepted JSON fail closed, including both + standard errors, Wilson upper, and temporal-order accuracy; +- accepted/running status cannot carry a terminal artifact; +- `succeeded_scientific_acceptance` round-trips with matching digest and + schema; +- profile mismatch, missing artifact, failed-terminal artifact, identity + mismatch, model mismatch, future cutoff, impossible metrics, inconsistent + SE-gate flag, and digest tamper fail closed. diff --git a/docs/validation/temporal-event-foundation.md b/docs/validation/temporal-event-foundation.md index 2668749f0..68dda0b07 100644 --- a/docs/validation/temporal-event-foundation.md +++ b/docs/validation/temporal-event-foundation.md @@ -59,6 +59,7 @@ This report tracks exact-head scientific and engineering evidence required befor | Unicode canonical identity | `corpus_split` | implemented-main | merged PR #59 | NFC/NFD and Hangul canonical-equivalence links, duplicate/empty refusal, connected-group co-partition | ADR 0004/0008/0013; `docs/research/unicode-canonical-identity.md` | | 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 | +| Scientific-acceptance terminal wire | `tepp_api` | active-PR | this PR | metric-free receipts; `tepp.scientific_acceptance.v1` only on succeeded terminal | GAP-003A; issue #166; `docs/research/scientific-acceptance-terminal-wire.md` | | Mention-confidence Brier score | `event_core` | active-PR | calibration vs binary truth | perfect 0 / half 0.25 RMSE | ADR 0003; `docs/research/mention-confidence-brier.md` | | Checkpoint is not the estimator | `checkpoint_authority` | accepted-target | active PR | refuse checkpoint-as-estimator + unvalidated artifact + recovery vs estimator collapse | ADR 0001/0014 | | Scientific claim promotion gates | `validation_core` | active-PR | this PR | exact-head SHA + computed RMSE SE gate | ADR 0014; full release bundle remaining |