diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b495291a..397b2554a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- **Modality-source analysis-run profile**: `analysis_engine` binds existing `modality_source::refuse_modality_as_unique_content` and `refuse_modality_as_stopword_deletion` to cutoff-safe `modality_source_v1` (`tepp.modality_source.v1`) with inference status `non_lexical_modality_is_not_unique_content_not_stopword_deletion`. `identity_recovery_rate` stays library-side. Not prompt-source, not style-source, not copy-identity, not a simulation method-effect census, not GPU, not MCMC, and not topic birth/split/merge. - Removed the repository-local hourly PR-maintenance caller now covered by the central required scheduler, retired stale workflow registrations, narrowed documentation triggers, keyed PR concurrency by fixed workflow name, repository, and pull-request number without cancelling non-PR runs, and combined line/branch coverage on one sequential runner while preserving both 100% gates and diagnostics. - `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. diff --git a/Cargo.lock b/Cargo.lock index 454a7d612..4bccdbd57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,7 @@ dependencies = [ "corpus_split", "event_core", "membership_core", + "modality_source", "relation_graph", "serde", "serde_json", diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..f6a49d2b7 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -71,6 +71,7 @@ TEPP's approved PRD v0.4 and implementation plan are the primary product baselin | Hourly NIM OpenCode doctoring | [`docs/doctoring/hourly-nim-opencode-development.md`](docs/doctoring/hourly-nim-opencode-development.md) | | Analysis engine v1 doctoring | [`docs/doctoring/analysis-engine-v1.md`](docs/doctoring/analysis-engine-v1.md) | | Analysis engine gap-closure doctoring | [`docs/doctoring/analysis-engine-gap-closure.md`](docs/doctoring/analysis-engine-gap-closure.md) | +| Modality-source analysis-run doctoring | [`docs/doctoring/modality-source-analysis-run.md`](docs/doctoring/modality-source-analysis-run.md) | | Corpus-split leakage-audit wire doctoring | [`docs/research/corpus-split-manifest-wire.md`](docs/research/corpus-split-manifest-wire.md) | | Unicode canonical-identity doctoring | [`docs/research/unicode-canonical-identity.md`](docs/research/unicode-canonical-identity.md) | | Change history | [`CHANGELOG.md`](CHANGELOG.md) | diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 7322212b2..afb384588 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -14,7 +14,9 @@ categories.workspace = true publish = false [dependencies] +corpus_split = { path = "../corpus_split", version = "0.2.0" } event_core = { path = "../event_core", version = "0.2.0" } +modality_source = { path = "../modality_source", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } @@ -24,7 +26,6 @@ topic_measurement = { path = "../topic_measurement", version = "0.2.0" } uuid.workspace = true [dev-dependencies] -corpus_split = { path = "../corpus_split", version = "0.2.0" } membership_core = { path = "../membership_core", version = "0.2.0" } relation_graph = { path = "../relation_graph", version = "0.2.0" } diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..3fbb4e96f 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -12,6 +12,7 @@ mod case_deletion_refit; mod lineage_criterion; +mod modality_source_artifact; mod topic_context_posterior; mod topic_lineage_artifact; @@ -46,6 +47,12 @@ pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, fit_lineage_criterion_posteriors, }; +/// Modality-source artifact and execution contracts from this engine. +pub use modality_source_artifact::{ + MODALITY_SOURCE_ARTIFACT_BYTE_LIMIT, MODALITY_SOURCE_ARTIFACT_SCHEMA_VERSION, + MODALITY_SOURCE_MODEL_CONTRACT_VERSION, MODALITY_SOURCE_OUTPUT_PROFILE, ModalitySourceArtifact, + ModalitySourceDocument, ModalitySourceExecution, execute_modality_source_run, +}; /// Bounded posterior topic-context producer contract and record types. pub use topic_context_posterior::{ TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, @@ -248,6 +255,8 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// A modality-source artifact violated its bounded schema or count invariants. + InvalidModalitySourceArtifact, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +271,7 @@ impl fmt::Display for AnalysisEngineError { Self::LimitExceeded => "analysis corpus exceeded its execution bound", Self::TopicMeasurement(error) => return error.fmt(formatter), Self::InvalidTopicLineageArtifact => "invalid topic lineage artifact", + Self::InvalidModalitySourceArtifact => "invalid modality-source artifact", }; formatter.write_str(message) } @@ -681,6 +691,10 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidModalitySourceArtifact, + "invalid modality-source artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/src/modality_source_artifact.rs b/crates/analysis_engine/src/modality_source_artifact.rs new file mode 100644 index 000000000..744d84404 --- /dev/null +++ b/crates/analysis_engine/src/modality_source_artifact.rs @@ -0,0 +1,444 @@ +//! Digest-bound non-lexical modality refusals as an analysis-run profile. + +use corpus_split::cutoff_eligible; +use modality_source::{ + ModalityKind, ModalitySourceError, refuse_modality_as_stopword_deletion, + refuse_modality_as_unique_content, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, format_digest, require_receipt_identity, + valid_identifier, +}; + +/// Versioned schema for a completed modality-source artifact. +pub const MODALITY_SOURCE_ARTIFACT_SCHEMA_VERSION: &str = "tepp.modality_source.v1"; +/// Model contract required by the modality-source execution path. +pub const MODALITY_SOURCE_MODEL_CONTRACT_VERSION: &str = "modality_source_v1"; +/// Analysis-run output profile required for a modality-source artifact. +pub const MODALITY_SOURCE_OUTPUT_PROFILE: &str = "modality_source_v1"; +/// Maximum canonical artifact JSON size. +pub const MODALITY_SOURCE_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const MODALITY_SOURCE_INFERENCE_STATUS: &str = + "non_lexical_modality_is_not_unique_content_not_stopword_deletion"; + +/// One token treatment with immutable snapshot and availability provenance. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ModalitySourceDocument { + document_id: String, + kind: ModalityKind, + snapshot_id: String, + available_time: AvailableTime, +} + +impl ModalitySourceDocument { + /// Construct a bounded modality-source document with explicit provenance. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidEvidence`] when the document or + /// snapshot identity is empty or oversized. + pub fn new( + document_id: impl Into, + kind: ModalityKind, + snapshot_id: impl Into, + available_time: AvailableTime, + ) -> Result { + let document_id = document_id.into(); + let snapshot_id = snapshot_id.into(); + if !valid_identifier(&document_id) || !valid_identifier(&snapshot_id) { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(Self { + document_id, + kind, + snapshot_id, + available_time, + }) + } + + /// Return the opaque document identity. + #[must_use] + pub fn document_id(&self) -> &str { + &self.document_id + } + + /// Return the closed modality-source kind. + #[must_use] + pub const fn kind(&self) -> ModalityKind { + self.kind + } + + /// Return the immutable source snapshot identity. + #[must_use] + pub fn snapshot_id(&self) -> &str { + &self.snapshot_id + } + + /// Return when the document became available for historical analysis. + #[must_use] + pub const fn available_time(&self) -> &AvailableTime { + &self.available_time + } +} + +/// Completed, bounded modality-source census for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ModalitySourceArtifact { + /// Exact versioned schema identity. + pub schema_version: String, + /// Opaque accepted-run identity. + pub run_id: String, + /// Immutable source snapshot identity. + pub snapshot_id: String, + /// Historical evidence cutoff used to admit documents. + pub knowledge_cutoff: String, + /// Number of documents admitted at the cutoff. + pub document_count: u64, + /// Unique-content treatments admitted at the cutoff. + pub unique_content_count: u64, + /// Non-lexical modality treatments admitted at the cutoff. + pub non_lexical_modality_count: u64, + /// Non-lexical modality refused as unique latent content. + pub refused_as_unique_content_count: u64, + /// Non-lexical modality refused as stopword deletion. + pub refused_as_stopword_deletion_count: u64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl ModalitySourceArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidModalitySourceArtifact`] when the + /// schema, identifiers, counts, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > MODALITY_SOURCE_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidModalitySourceArtifact)?; + artifact.validate()?; + Ok(artifact) + } + + /// Serialize canonical validated artifact JSON. + /// + /// # Errors + /// + /// Returns a typed validation, serialization, or size failure. + pub fn to_json(&self) -> Result { + self.validate()?; + let payload = + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; + if payload.len() > MODALITY_SOURCE_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + Ok(payload) + } + + /// Return the lowercase SHA-256 digest of canonical artifact JSON. + /// + /// # Errors + /// + /// Returns a typed validation or serialization failure. + pub fn sha256(&self) -> Result { + self.to_json() + .map(|json| format_digest(Sha256::digest(json.into_bytes()))) + } + + fn validate(&self) -> Result<(), AnalysisEngineError> { + let kind_sum = self + .unique_content_count + .checked_add(self.non_lexical_modality_count); + if self.schema_version != MODALITY_SOURCE_ARTIFACT_SCHEMA_VERSION + || !valid_identifier(&self.run_id) + || !valid_identifier(&self.snapshot_id) + || KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff).is_err() + || self.document_count < 2 + || self.document_count > MAX_EVIDENCE_UNITS as u64 + || self.unique_content_count == 0 + || self.non_lexical_modality_count == 0 + || kind_sum != Some(self.document_count) + || self.refused_as_unique_content_count != self.non_lexical_modality_count + || self.refused_as_stopword_deletion_count != self.non_lexical_modality_count + || self.inference_status != MODALITY_SOURCE_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidModalitySourceArtifact); + } + Ok(()) + } +} + +/// One completed modality-source artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct ModalitySourceExecution { + /// Digest-bound completed modality-source census. + pub artifact: ModalitySourceArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute cutoff-safe non-lexical modality refusals as one analysis-run profile. +/// +/// The executor invokes [`refuse_modality_as_unique_content`] and +/// [`refuse_modality_as_stopword_deletion`] already on protected main. It does +/// not emit `identity_recovery_rate`, a `scientific_acceptance` inspect +/// metric, GPU kernels, MCMC, or topic birth/split/merge events. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, empty or +/// single-kind admitted corpus, duplicate admitted document identity, +/// oversized raw corpus, or invalid artifact error. +pub fn execute_modality_source_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + documents: &[ModalitySourceDocument], + completed_at: impl Into, +) -> Result { + request.to_json()?; + accepted.to_json()?; + require_receipt_identity(request, accepted)?; + if request.snapshot_id != snapshot_id { + return Err(AnalysisEngineError::SnapshotMismatch); + } + let request_cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if request_cutoff.instant() != knowledge_cutoff.instant() + || request.model_contract_version != MODALITY_SOURCE_MODEL_CONTRACT_VERSION + || request.output_profile != MODALITY_SOURCE_OUTPUT_PROFILE + { + return Err(AnalysisEngineError::InvalidEvidence); + } + if documents.len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + + let mut seen = std::collections::BTreeSet::new(); + let mut unique_content_count = 0_u64; + let mut non_lexical_modality_count = 0_u64; + let mut refused_as_unique_content_count = 0_u64; + let mut refused_as_stopword_deletion_count = 0_u64; + for document in documents { + if document.snapshot_id() != snapshot_id { + return Err(AnalysisEngineError::InvalidEvidence); + } + if !cutoff_eligible(document.available_time(), &knowledge_cutoff) { + continue; + } + if !seen.insert(document.document_id()) { + return Err(AnalysisEngineError::DuplicateEvidence); + } + match document.kind() { + ModalityKind::UniqueContent => { + refuse_modality_as_unique_content(document.kind()).map_err(map_modality_error)?; + refuse_modality_as_stopword_deletion(document.kind()) + .map_err(map_modality_error)?; + unique_content_count = unique_content_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + ModalityKind::NonLexicalModality => { + match refuse_modality_as_unique_content(document.kind()) { + Err(ModalitySourceError::ModalityIsNotUniqueContent) => { + refused_as_unique_content_count = refused_as_unique_content_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), + } + match refuse_modality_as_stopword_deletion(document.kind()) { + Err(ModalitySourceError::ModalityIsNotStopwordDeletion) => { + refused_as_stopword_deletion_count = refused_as_stopword_deletion_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + Ok(()) | Err(_) => return Err(AnalysisEngineError::InvalidEvidence), + } + non_lexical_modality_count = non_lexical_modality_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + } + } + let document_count = + u64::try_from(seen.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + if document_count < 2 || unique_content_count == 0 || non_lexical_modality_count == 0 { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let artifact = ModalitySourceArtifact { + schema_version: MODALITY_SOURCE_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + document_count, + unique_content_count, + non_lexical_modality_count, + refused_as_unique_content_count, + refused_as_stopword_deletion_count, + inference_status: MODALITY_SOURCE_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new("modality_source", document_count, 4, "validated")?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("modality_source_artifact_{}", &digest[..16]), + digest, + MODALITY_SOURCE_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(ModalitySourceExecution { + artifact, + terminal_result, + }) +} + +fn map_modality_error(error: ModalitySourceError) -> AnalysisEngineError { + match error { + ModalitySourceError::ModalityIsNotUniqueContent + | ModalitySourceError::ModalityIsNotStopwordDeletion + | ModalitySourceError::InvalidModalityPayload + | _ => AnalysisEngineError::InvalidEvidence, + } +} + +#[cfg(test)] +mod tests { + use super::{ + MODALITY_SOURCE_ARTIFACT_BYTE_LIMIT, MODALITY_SOURCE_ARTIFACT_SCHEMA_VERSION, + MODALITY_SOURCE_INFERENCE_STATUS, ModalitySourceArtifact, + }; + use crate::{AnalysisEngineError, MAX_EVIDENCE_UNITS}; + + fn artifact() -> ModalitySourceArtifact { + ModalitySourceArtifact { + schema_version: MODALITY_SOURCE_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + document_count: 3, + unique_content_count: 1, + non_lexical_modality_count: 2, + refused_as_unique_content_count: 2, + refused_as_stopword_deletion_count: 2, + inference_status: MODALITY_SOURCE_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &ModalitySourceArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidModalitySourceArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + ModalitySourceArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + ModalitySourceArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidModalitySourceArtifact) + ); + assert_eq!( + ModalitySourceArtifact::from_json(&"x".repeat(MODALITY_SOURCE_ARTIFACT_BYTE_LIMIT + 1)), + Err(AnalysisEngineError::LimitExceeded) + ); + } + + #[test] + fn artifact_metadata_tampering_fails_closed() { + let artifact = artifact(); + let invalid_artifacts = [ + { + let mut value = artifact.clone(); + value.schema_version.clear(); + value + }, + { + let mut value = artifact.clone(); + value.run_id.clear(); + value + }, + { + let mut value = artifact.clone(); + value.snapshot_id.clear(); + value + }, + { + let mut value = artifact.clone(); + value.knowledge_cutoff = "invalid".into(); + value + }, + { + let mut value = artifact.clone(); + value.document_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.document_count = u64::try_from(MAX_EVIDENCE_UNITS).expect("bound") + 1; + value.unique_content_count = value.document_count - 1; + value.non_lexical_modality_count = 1; + value.refused_as_unique_content_count = 1; + value.refused_as_stopword_deletion_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.unique_content_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.non_lexical_modality_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.unique_content_count = u64::MAX; + value.non_lexical_modality_count = 1; + value.document_count = u64::MAX; + value + }, + { + let mut value = artifact.clone(); + value.refused_as_unique_content_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.refused_as_stopword_deletion_count = 1; + value + }, + { + let mut value = artifact.clone(); + value.inference_status.clear(); + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } +} diff --git a/crates/analysis_engine/tests/modality_source_execution_contract.rs b/crates/analysis_engine/tests/modality_source_execution_contract.rs new file mode 100644 index 000000000..521fcc759 --- /dev/null +++ b/crates/analysis_engine/tests/modality_source_execution_contract.rs @@ -0,0 +1,327 @@ +//! End-to-end contract for cutoff-safe non-lexical modality refusals. + +use analysis_engine::{ + AnalysisEngineError, MAX_EVIDENCE_UNITS, MODALITY_SOURCE_ARTIFACT_SCHEMA_VERSION, + MODALITY_SOURCE_MODEL_CONTRACT_VERSION, MODALITY_SOURCE_OUTPUT_PROFILE, ModalitySourceDocument, + execute_modality_source_run, +}; +use modality_source::ModalityKind; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("available") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "modality-source-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-modality-source".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: MODALITY_SOURCE_MODEL_CONTRACT_VERSION.into(), + output_profile: MODALITY_SOURCE_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-modality-source", "accepted", &request.idempotency_key) + .expect("accepted") +} + +fn document( + document_id: &str, + kind: ModalityKind, + available_time: &str, +) -> ModalitySourceDocument { + ModalitySourceDocument::new( + document_id, + kind, + "snapshot-modality-source", + available(available_time), + ) + .expect("document") +} + +fn mixed_documents() -> Vec { + vec![ + document( + "unique-a", + ModalityKind::UniqueContent, + "2026-07-31T22:00:00Z", + ), + document( + "modality-b", + ModalityKind::NonLexicalModality, + "2026-07-31T23:00:00Z", + ), + document( + "modality-c", + ModalityKind::NonLexicalModality, + "2026-08-01T00:00:00Z", + ), + ] +} + +fn execute( + request: &AnalysisRunRequest, + documents: &[ModalitySourceDocument], +) -> Result { + execute_modality_source_run( + request, + &accepted(request), + "snapshot-modality-source", + cutoff(), + documents, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn mixed_modality_kinds_emit_digest_bound_refusals_without_recovery_metric() { + let request = request(); + let execution = execute(&request, &mixed_documents()).expect("execution"); + assert_eq!( + execution.artifact.schema_version, + MODALITY_SOURCE_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.document_count, 3); + assert_eq!(execution.artifact.unique_content_count, 1); + assert_eq!(execution.artifact.non_lexical_modality_count, 2); + assert_eq!(execution.artifact.refused_as_unique_content_count, 2); + assert_eq!(execution.artifact.refused_as_stopword_deletion_count, 2); + assert_eq!( + execution.artifact.inference_status, + "non_lexical_modality_is_not_unique_content_not_stopword_deletion" + ); + let payload = execution.artifact.to_json().expect("json"); + assert!(!payload.contains("identity_recovery_rate")); + assert!(!payload.contains("scientific_acceptance")); + assert_eq!( + execution.terminal_result.run_state, + AnalysisRunTerminalState::Succeeded + ); + assert_eq!( + execution.terminal_result.result_sha256.as_deref(), + Some(execution.artifact.sha256().expect("digest").as_str()) + ); + assert_eq!( + execution.terminal_result.result_schema_version.as_deref(), + Some(MODALITY_SOURCE_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[test] +fn equivalent_rfc3339_cutoff_spellings_bind_the_same_instant() { + let mut equivalent = request(); + equivalent.knowledge_cutoff = "2026-08-01T01:00:00+01:00".into(); + let execution = execute_modality_source_run( + &equivalent, + &accepted(&equivalent), + "snapshot-modality-source", + cutoff(), + &mixed_documents(), + "2026-08-02T00:00:00Z", + ) + .expect("equivalent cutoff instant"); + assert_eq!(execution.artifact.knowledge_cutoff, cutoff().to_rfc3339()); +} + +#[test] +fn terminal_summary_keeps_validation_status_separate_from_domain_inference() { + let request = request(); + let execution = execute(&request, &mixed_documents()).expect("execution"); + let summary = execution + .terminal_result + .summary + .as_ref() + .expect("succeeded summary"); + assert_eq!(summary.validation_status, "validated"); + assert_ne!(summary.validation_status, execution.artifact.inference_status); +} + +#[test] +fn future_unavailable_duplicate_cannot_change_historical_replay() { + let request = request(); + let baseline = execute(&request, &mixed_documents()).expect("baseline"); + let mut with_future = vec![document( + "unique-a", + ModalityKind::UniqueContent, + "2026-08-01T00:00:01Z", + )]; + with_future.extend(mixed_documents()); + let replay = execute(&request, &with_future).expect("historical replay"); + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result, baseline.terminal_result); +} + +#[test] +fn cross_snapshot_document_fails_before_aggregation() { + let request = request(); + let mut documents = mixed_documents(); + documents.push( + ModalitySourceDocument::new( + "modality-other-snapshot", + ModalityKind::NonLexicalModality, + "other-snapshot", + available("2026-07-31T23:30:00Z"), + ) + .expect("cross-snapshot document"), + ); + assert_eq!( + execute(&request, &documents), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn raw_census_bound_precedes_identity_allocation_and_duplicate_checks() { + let request = request(); + let repeated = document( + "repeated", + ModalityKind::UniqueContent, + "2026-07-31T22:00:00Z", + ); + let documents = vec![repeated; MAX_EVIDENCE_UNITS + 1]; + assert_eq!( + execute(&request, &documents), + Err(AnalysisEngineError::LimitExceeded) + ); +} + +#[test] +fn empty_unique_only_modality_only_and_duplicate_identities_fail_closed() { + let request = request(); + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let unique_only = vec![ + document( + "unique-a", + ModalityKind::UniqueContent, + "2026-07-31T22:00:00Z", + ), + document( + "unique-b", + ModalityKind::UniqueContent, + "2026-07-31T23:00:00Z", + ), + ]; + assert_eq!( + execute(&request, &unique_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let modality_only = vec![ + document( + "modality-a", + ModalityKind::NonLexicalModality, + "2026-07-31T22:00:00Z", + ), + document( + "modality-b", + ModalityKind::NonLexicalModality, + "2026-07-31T23:00:00Z", + ), + ]; + assert_eq!( + execute(&request, &modality_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + document( + "same", + ModalityKind::UniqueContent, + "2026-07-31T22:00:00Z", + ), + document( + "same", + ModalityKind::NonLexicalModality, + "2026-07-31T23:00:00Z", + ), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + ModalitySourceDocument::new( + "", + ModalityKind::UniqueContent, + "snapshot-modality-source", + available("2026-07-31T22:00:00Z"), + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + ModalitySourceDocument::new( + "unique-a", + ModalityKind::UniqueContent, + "", + available("2026-07-31T22:00:00Z"), + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + let documents = mixed_documents(); + assert_eq!( + execute_modality_source_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); + let mut mismatched = request.clone(); + mismatched.knowledge_cutoff = "2026-07-01T00:00:00Z".into(); + assert_eq!( + execute_modality_source_run( + &mismatched, + &accepted(&mismatched), + "snapshot-modality-source", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + for profile in [ + "trsl_topic_lineage_v1", + "fitted_candidate_k_v1", + "pareto_candidate_k_v1", + "joint_posterior_draws_v1", + "method_effects_v1", + "copy_identity_v1", + "style_source_v1", + "prompt_source_v1", + "composed_fitted_lineage_v1", + "case_deletion_refit_v1", + "topic_activity_v1", + ] { + let mut reused = request.clone(); + reused.output_profile = profile.into(); + assert_eq!( + execute_modality_source_run( + &reused, + &accepted(&reused), + "snapshot-modality-source", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..2665531b2 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -58,6 +58,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); HTTP service remaining accepted-target | partial | | versioned service/API contracts and exports | PRD; API contract; ADR 0011/0013 | `tepp_api` analysis-run/export/JSON-LD/GraphML contracts on protected main (PR #21); LineageWeave loopback contracts and request-bound terminal result are composed on the active product branch; production TLS remaining | partial | | executable cutoff-safe analysis runs | ADR 0012/0022; temporal research; API terminal-result contract | `analysis_engine` availability cutoff, snapshot binding, multiple-membership aggregation, digest-bound readiness artifact, and `tepp.trsl_topic_lineage.v1` execution through `topic_measurement`; synthetic recovery plus tamper/non-convergence tests and exact coverage on the active product branch | active-PR | +| modality-source analysis-run profile | ADR 0004/0012/0022/0061; ModalityKind NonLexicalModality/UniqueContent | `analysis_engine` `modality_source_v1` binds `refuse_modality_as_unique_content` and `refuse_modality_as_stopword_deletion`; digest-bound refusals, not `identity_recovery_rate` inspect metric, not GPU, not MCMC, not topic birth/split/merge; not implemented-main | active-PR | | immutable split/run/reproducibility manifests | ADR 0013; ERD | `tepp_api` reproducibility manifest contract on protected main; `persistence_postgres` append-only SQL insert/lookup for `reproducibility_manifest`, `corpus_split_manifest`, `model_run`, and `model_artifact` (migration `0003`); full physical ERD constraints remaining | partial | | multilingual shared latent semantic space | PRD; ADR 0004; ADR 0020 | `semantic_core` span-grounded units (active-PR); concept dictionary and shared latent estimator remaining | active-PR | | TRSL-TM temporal/relational topic posterior and backend compatibility | ADR 0012; ADR 0004 | `topic_measurement` stable ALR/ILR coordinates and bounded CPU `f64` reference estimator on protected main; `model_selection` fitted candidate-`K` scoring on this PR; calibrated posterior promotion, method effects, persistence, and accelerated backends remaining | partial | diff --git a/docs/adr/0061-modality-source-analysis-run.md b/docs/adr/0061-modality-source-analysis-run.md new file mode 100644 index 000000000..2f16d4856 --- /dev/null +++ b/docs/adr/0061-modality-source-analysis-run.md @@ -0,0 +1,123 @@ +# ADR 0061 — Non-lexical modality refusals as an analysis-run output profile + +**Decision status:** Proposed +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0004/0012 (non-lexical modality is not unique content) and ADR 0022 (cutoff-safe analysis-run execution). Does not reuse ADR 0060 (prompt-source), ADR 0059 (style-source), ADR 0058 (copy-identity), or ADR 0057 (simulation method-effect census). +**Figma File ID:** N/A — this increment changes a Rust service crate and has no user-interface surface. +**Storybook inventory:** N/A — no reusable web object or interaction changed. + +## Context + +Protected main already refuses to treat non-lexical modality as unique +latent content or as stopword deletion via +`modality_source::refuse_modality_as_unique_content` and +`refuse_modality_as_stopword_deletion`. Operators still cannot request that +refusal census as a digest-bound analysis-run output. Prompt-source +refusals (#419 / ADR 0060) bind `PromptKind` and do not replace +`modality_source`. + +The original branch shape was not historically safe enough to support that +claim. `ModalitySourceDocument` carried neither immutable snapshot provenance +nor `AvailableTime`, raw census size was not bounded before identity-set +allocation, request and executor cutoffs were compared as RFC 3339 text rather +than instants, and terminal `validation_status` reused the domain inference +label. Those are application-boundary defects rather than changes to the +`modality_source` domain vocabulary. + +`identity_recovery_rate` stays library-side. This slice does not put a +`scientific_acceptance` metric on inspect payloads. + +GPU kernels, MCMC, and topic birth/split/merge remain later GAP-004 work +and are not this slice. + +## Decision + +Add the `modality_source_v1` analysis-run output profile to +`analysis_engine`. The proposed executor: + +- accepts `ModalitySourceDocument` rows with explicit immutable `snapshot_id`, + `AvailableTime`, and closed `ModalityKind` values; +- compares request and executor knowledge cutoffs by parsed instant, so + equivalent RFC 3339 spellings bind to the same historical instant; +- rejects cross-snapshot evidence before aggregation; +- excludes same-snapshot evidence with `AvailableTime > knowledge_cutoff` + before duplicate-identity and domain admission, so post-cutoff evidence + cannot perturb a historical replay; +- continues to reject duplicate identities among evidence that is actually + visible at the cutoff; +- rejects raw censuses above `MAX_EVIDENCE_UNITS` before constructing the + identity set; +- invokes `refuse_modality_as_unique_content` and + `refuse_modality_as_stopword_deletion` without reimplementing the + modality/content vocabulary; +- emits a canonical SHA-256-digested `tepp.modality_source.v1` artifact with + admitted unique-content/non-lexical-modality counts, matching refusal + counts, and inference status + `non_lexical_modality_is_not_unique_content_not_stopword_deletion`; +- reports terminal provider validation state as `validated`, separate from the + domain inference label; +- does not emit `identity_recovery_rate`, invent MCMC, select GPU backends, or + emit topic birth/split/merge events. + +The artifact `document_count` is the cutoff-admitted identity count, not raw +input length. The raw admission bound remains operational and is checked before +historical censoring. + +## Alternatives considered + +1. Duplicate prompt-source refusals (#419) — rejected because that profile + binds `PromptKind` PromptBoilerplate/UniqueContent and does not bind + `modality_source`. +2. Treat post-cutoff evidence as a hard failure — rejected for same-snapshot + evidence because historical replay must be invariant to later-arriving rows; + cutoff censoring is the established Analysis Run behavior. Cross-snapshot + evidence remains a hard admission failure because it violates snapshot + provenance rather than time eligibility. +3. Compare RFC 3339 cutoff strings verbatim — rejected because different legal + offsets can denote the same instant. +4. Put `identity_recovery_rate` on the operator artifact — rejected because + inspect payloads stay metric-free and `tepp.scientific_acceptance.v1` never + appears. +5. Bind the existing modality-source refusals to ADR 0022's analysis-run + profile — selected, subject to protected-main landing and current-head + evidence. + +## Consequences + +A historical replay is invariant to same-snapshot rows that became available +after the requested cutoff. Snapshot provenance cannot be silently reassigned, +and visible duplicate identities still fail closed. Memory growth is bounded by +raw admission before `BTreeSet` allocation. Provider validation status no +longer masquerades as a domain inference claim. + +The artifact does not claim MCMC, GPU parity, prompt-source, style-source, +copy-identity, method-effect estimation, or topic birth/split/merge. Because +this decision is implemented only on an unmerged Draft branch, it remains +`Proposed`; protected main is the authority for acceptance. + +## Verification + +The PR includes Rust unit and integration tests for mixed unique/modality +corpora, equivalent cutoff spellings, cutoff-equality admission, post-cutoff +historical replay invariance, cross-snapshot refusal, raw census bounds, +empty/unique-only/modality-only/visible-duplicate refusal, +snapshot/profile/cutoff mismatch, terminal validation-state separation, and +artifact tampering. Run: + +```text +cargo fmt --all -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +python3 scripts/validate_documentation.py +``` + +No predecessor check or review receipt transfers across a head change. + +## Rollback and supersession + +Rollback removes the `modality_source_v1` profile. No persisted schema +migration is introduced. Supersede only with an ADR that keeps non-lexical +modality distinct from prompt-source refusals and from +`identity_recovery_rate` inspect metrics, and that preserves explicit snapshot +and availability provenance for historical replay. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..4b6da6298 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,6 +30,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | +| [0061](0061-modality-source-analysis-run.md) | Non-lexical modality refusals as an analysis-run profile | Accepted | active-PR | Complements ADR 0004/0012/0022; `refuse_modality_as_unique_content` + `refuse_modality_as_stopword_deletion`, not prompt-source. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | | [0024](0024-independent-topic-importance-anchor.md) | Posterior topic-context producer contract | Accepted | contract-only active-PR | Strict DTO/schema only; the current estimator does not emit it. fast-mlsirm owns case-deletion influence. | | [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. | @@ -138,6 +139,7 @@ Use the narrowest owning ADR when decisions overlap: - **project-history wire-size symmetry:** ADR 0019. - **LineageWeave project-history service boundary:** ADR 0021. - **accepted-run execution and terminal artifact production:** ADR 0022. +- **modality-source analysis-run profile:** ADR 0061. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/modality-source-analysis-run.md b/docs/doctoring/modality-source-analysis-run.md new file mode 100644 index 000000000..b73bdcf15 --- /dev/null +++ b/docs/doctoring/modality-source-analysis-run.md @@ -0,0 +1,35 @@ +# Non-lexical modality analysis-run composition + +**Active slice:** ADR 0061 / `modality_source_v1` +**Decision status:** Proposed +**Protected-main status:** not implemented-main + +`modality_source` already refuses to treat non-lexical modality as unique +latent content or as stopword deletion. This slice binds those refusals +to a digest-bound analysis-run profile while preserving historical replay. + +Each `ModalitySourceDocument` carries immutable `snapshot_id` and +`AvailableTime` provenance. Cross-snapshot evidence fails before aggregation. +Same-snapshot evidence that became available after `knowledge_cutoff` is +excluded before duplicate and domain admission, so later rows cannot change an +earlier result; duplicate identities that were actually visible at the cutoff +still fail closed. Raw input is bounded by `MAX_EVIDENCE_UNITS` before identity +allocation. Request and executor cutoffs compare parsed instants rather than RFC +3339 spelling. + +The artifact inference status is +`non_lexical_modality_is_not_unique_content_not_stopword_deletion` while the +terminal provider validation state is separately `validated`. +`identity_recovery_rate` stays library-side. This is not a prompt-source +census, not style-source, not copy-identity, not a simulation +method-effect census, not GPU, not MCMC, and not topic birth/split/merge. + +Current RED-to-repair lineage begins with `0a08fcab054903e907ccbef5ff8694218159b52a` +for equivalent-cutoff/validation-state regressions and +`edb5d3351265d100caf260e6d8ddf207c39e3003` for explicit provenance, +historical replay, cross-snapshot and raw-census contracts. Repairs are +`5a9dac8cdc05665ee5b1e78c9c69c9b18b429d33` for instant binding/provider +validation state and `e5c1555a6d2c7f10defa0471f45d358b96954c34` for provenance-aware cutoff +admission. Dependency ownership was corrected in `ab733e9ffd2111a6c1c63aa5fc981678a5682638`. +Hosted current-head checks remain authoritative; these commit IDs are repair +traceability, not transferred acceptance evidence.