diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b495291a..1d27b859d 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] +- **Copied-text analysis-run profile**: `analysis_engine` binds existing `copied_text::refuse_copied_text_as_unique_content` and `refuse_copied_text_as_stopword_deletion` to cutoff-safe `copied_text_v1` (`tepp.copied_text.v1`) with inference status `copied_text_is_not_unique_content_not_stopword_deletion`. `identity_recovery_rate` stays library-side. Not template-copy identity, not citation-edge, not corpus-background, not modality-source, not prompt-source, not style-source, 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..b1b1bf8b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,6 +71,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" name = "analysis_engine" version = "0.2.0" dependencies = [ + "copied_text", "corpus_split", "event_core", "membership_core", diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6fa4b9683..7069828da 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) | +| Copied-text analysis-run doctoring | [`docs/doctoring/copied-text-analysis-run.md`](docs/doctoring/copied-text-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..ac4b49976 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -21,10 +21,11 @@ sha2 = { workspace = true } tepp_api = { path = "../tepp_api", version = "0.2.0" } temporal_core = { path = "../temporal_core", version = "0.2.0" } topic_measurement = { path = "../topic_measurement", version = "0.2.0" } +copied_text = { path = "../copied_text", version = "0.2.0" } +corpus_split = { path = "../corpus_split", 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/copied_text_artifact.rs b/crates/analysis_engine/src/copied_text_artifact.rs new file mode 100644 index 000000000..258df67e5 --- /dev/null +++ b/crates/analysis_engine/src/copied_text_artifact.rs @@ -0,0 +1,507 @@ +//! Digest-bound copied-text refusals as an analysis-run profile. + +use copied_text::{ + CopiedKind, CopiedTextError, refuse_copied_text_as_stopword_deletion, + refuse_copied_text_as_unique_content, +}; +use corpus_split::cutoff_eligible; +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 copied-text artifact. +pub const COPIED_TEXT_ARTIFACT_SCHEMA_VERSION: &str = "tepp.copied_text.v1"; +/// Model contract required by the copied-text execution path. +pub const COPIED_TEXT_MODEL_CONTRACT_VERSION: &str = "copied_text_v1"; +/// Analysis-run output profile required for a copied-text artifact. +pub const COPIED_TEXT_OUTPUT_PROFILE: &str = "copied_text_v1"; +/// Maximum accepted copied-text artifact JSON size. +pub const COPIED_TEXT_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const COPIED_TEXT_INFERENCE_STATUS: &str = + "copied_text_is_not_unique_content_not_stopword_deletion"; + +/// One copied-text treatment with immutable snapshot and availability provenance. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CopiedTextDocument { + document_id: String, + kind: CopiedKind, + snapshot_id: String, + available_time: AvailableTime, +} + +impl CopiedTextDocument { + /// Construct a bounded copied-text 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: CopiedKind, + 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 copied-text kind. + #[must_use] + pub const fn kind(&self) -> CopiedKind { + self.kind + } + + /// Return the immutable source snapshot identity. + #[must_use] + pub fn snapshot_id(&self) -> &str { + &self.snapshot_id + } + + /// Return when this treatment became available for historical analysis. + #[must_use] + pub const fn available_time(&self) -> &AvailableTime { + &self.available_time + } +} + +/// Completed, bounded copied-text census for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CopiedTextArtifact { + /// 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, + /// Copied-text treatments admitted at the cutoff. + pub copied_text_count: u64, + /// Copied-text residue refused as unique latent content. + pub refused_as_unique_content_count: u64, + /// Copied-text residue refused as stopword deletion. + pub refused_as_stopword_deletion_count: u64, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl CopiedTextArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidCopiedTextArtifact`] when the + /// schema, identifiers, counts, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > COPIED_TEXT_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidCopiedTextArtifact)?; + artifact.validate()?; + Ok(artifact) + } + + /// Serialize canonical validated artifact JSON. + /// + /// The validated identifier, strict timestamp syntax, and census bounds + /// make canonical output strictly smaller than + /// [`COPIED_TEXT_ARTIFACT_BYTE_LIMIT`]. The input cap remains enforced by + /// [`Self::from_json`]. + /// + /// # Errors + /// + /// Returns a typed validation or serialization failure. + pub fn to_json(&self) -> Result { + self.validate()?; + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) + } + + /// 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.copied_text_count); + if self.schema_version != COPIED_TEXT_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.copied_text_count == 0 + || kind_sum != Some(self.document_count) + || self.refused_as_unique_content_count != self.copied_text_count + || self.refused_as_stopword_deletion_count != self.copied_text_count + || self.inference_status != COPIED_TEXT_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidCopiedTextArtifact); + } + Ok(()) + } +} + +/// One completed copied-text artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct CopiedTextExecution { + /// Digest-bound completed copied-text census. + pub artifact: CopiedTextArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute cutoff-safe copied-text refusals as one analysis-run profile. +/// +/// The executor invokes [`refuse_copied_text_as_unique_content`] and +/// [`refuse_copied_text_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_copied_text_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + documents: &[CopiedTextDocument], + 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 != COPIED_TEXT_MODEL_CONTRACT_VERSION + || request.output_profile != COPIED_TEXT_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 copied_text_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() { + CopiedKind::UniqueContent => { + require_unique_content_result(refuse_copied_text_as_unique_content( + document.kind(), + ))?; + require_unique_content_result(refuse_copied_text_as_stopword_deletion( + document.kind(), + ))?; + unique_content_count = unique_content_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + } + CopiedKind::CopiedText => { + require_unique_content_refusal(refuse_copied_text_as_unique_content( + document.kind(), + ))?; + refused_as_unique_content_count = refused_as_unique_content_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + require_stopword_refusal(refuse_copied_text_as_stopword_deletion( + document.kind(), + ))?; + refused_as_stopword_deletion_count = refused_as_stopword_deletion_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + copied_text_count = copied_text_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 || copied_text_count == 0 { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let artifact = CopiedTextArtifact { + schema_version: COPIED_TEXT_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, + copied_text_count, + refused_as_unique_content_count, + refused_as_stopword_deletion_count, + inference_status: COPIED_TEXT_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new("copied_text", document_count, 4, "validated")?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("copied_text_artifact_{}", &digest[..16]), + digest, + COPIED_TEXT_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(CopiedTextExecution { + artifact, + terminal_result, + }) +} + +fn require_unique_content_result( + result: Result<(), CopiedTextError>, +) -> Result<(), AnalysisEngineError> { + match result { + Ok(()) => Ok(()), + Err(_) => Err(AnalysisEngineError::InvalidEvidence), + } +} + +fn require_unique_content_refusal( + result: Result<(), CopiedTextError>, +) -> Result<(), AnalysisEngineError> { + match result { + Err(CopiedTextError::CopiedTextIsNotUniqueContent) => Ok(()), + Ok(()) | Err(_) => Err(AnalysisEngineError::InvalidEvidence), + } +} + +fn require_stopword_refusal( + result: Result<(), CopiedTextError>, +) -> Result<(), AnalysisEngineError> { + match result { + Err(CopiedTextError::CopiedTextIsNotStopwordDeletion) => Ok(()), + Ok(()) | Err(_) => Err(AnalysisEngineError::InvalidEvidence), + } +} + +#[cfg(test)] +mod tests { + use super::{ + COPIED_TEXT_ARTIFACT_BYTE_LIMIT, COPIED_TEXT_ARTIFACT_SCHEMA_VERSION, + COPIED_TEXT_INFERENCE_STATUS, CopiedTextArtifact, require_stopword_refusal, + require_unique_content_refusal, require_unique_content_result, + }; + use crate::{ + AnalysisEngineError, MAX_ANALYSIS_IDENTIFIER_BYTES, MAX_EVIDENCE_UNITS, + }; + use copied_text::CopiedTextError; + + fn artifact() -> CopiedTextArtifact { + CopiedTextArtifact { + schema_version: COPIED_TEXT_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, + copied_text_count: 2, + refused_as_unique_content_count: 2, + refused_as_stopword_deletion_count: 2, + inference_status: COPIED_TEXT_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &CopiedTextArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidCopiedTextArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_input_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + CopiedTextArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + CopiedTextArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidCopiedTextArtifact) + ); + assert_eq!( + CopiedTextArtifact::from_json(&"x".repeat(COPIED_TEXT_ARTIFACT_BYTE_LIMIT + 1)), + Err(AnalysisEngineError::LimitExceeded) + ); + } + + #[test] + fn maximal_valid_artifact_stays_below_input_wire_limit() { + let maximum_count = u64::try_from(MAX_EVIDENCE_UNITS).expect("bounded census"); + let maximal = CopiedTextArtifact { + schema_version: COPIED_TEXT_ARTIFACT_SCHEMA_VERSION.into(), + run_id: "\\".repeat(MAX_ANALYSIS_IDENTIFIER_BYTES), + snapshot_id: "\\".repeat(MAX_ANALYSIS_IDENTIFIER_BYTES), + knowledge_cutoff: "2026-08-01T00:00:00.123456789+14:00".into(), + document_count: maximum_count, + unique_content_count: maximum_count - 1, + copied_text_count: 1, + refused_as_unique_content_count: 1, + refused_as_stopword_deletion_count: 1, + inference_status: COPIED_TEXT_INFERENCE_STATUS.into(), + }; + let payload = maximal.to_json().expect("maximal valid artifact"); + assert!(payload.len() < COPIED_TEXT_ARTIFACT_BYTE_LIMIT); + assert_eq!(CopiedTextArtifact::from_json(&payload), Ok(maximal)); + } + + #[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 + }, + { + let mut value = artifact.clone(); + value.unique_content_count = 0; + value + }, + { + let mut value = artifact.clone(); + value.copied_text_count = 0; + 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); + } + } + + #[test] + fn provider_result_guards_fail_closed_on_contract_drift() { + assert_eq!(require_unique_content_result(Ok(())), Ok(())); + assert_eq!( + require_unique_content_result(Err(CopiedTextError::InvalidCopiedPayload)), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + require_unique_content_refusal(Err(CopiedTextError::CopiedTextIsNotUniqueContent)), + Ok(()) + ); + assert_eq!( + require_unique_content_refusal(Ok(())), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + require_unique_content_refusal(Err(CopiedTextError::InvalidCopiedPayload)), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + require_stopword_refusal(Err(CopiedTextError::CopiedTextIsNotStopwordDeletion)), + Ok(()) + ); + assert_eq!( + require_stopword_refusal(Ok(())), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + require_stopword_refusal(Err(CopiedTextError::InvalidCopiedPayload)), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..dbc7fe66f 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -11,6 +11,7 @@ //! contracts and preserves their artifact meaning. mod case_deletion_refit; +mod copied_text_artifact; mod lineage_criterion; mod topic_context_posterior; mod topic_lineage_artifact; @@ -41,6 +42,12 @@ pub use case_deletion_refit::ExhaustiveCaseDeletionError; pub use case_deletion_refit::ExhaustiveCaseDeletionFits; /// Fit the full corpus and every actual one-document deletion. pub use case_deletion_refit::fit_exhaustive_case_deletion; +/// Copied-text artifact and execution contracts from this engine. +pub use copied_text_artifact::{ + COPIED_TEXT_ARTIFACT_BYTE_LIMIT, COPIED_TEXT_ARTIFACT_SCHEMA_VERSION, + COPIED_TEXT_MODEL_CONTRACT_VERSION, COPIED_TEXT_OUTPUT_PROFILE, CopiedTextArtifact, + CopiedTextDocument, CopiedTextExecution, execute_copied_text_run, +}; /// Rust-owned independent TDT link-criterion posterior fitting contracts. pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, @@ -248,6 +255,8 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// A copied-text artifact violated its bounded schema or count invariants. + InvalidCopiedTextArtifact, } 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::InvalidCopiedTextArtifact => "invalid copied-text artifact", }; formatter.write_str(message) } @@ -681,6 +691,10 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidCopiedTextArtifact, + "invalid copied-text artifact", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/tests/copied_text_execution_contract.rs b/crates/analysis_engine/tests/copied_text_execution_contract.rs new file mode 100644 index 000000000..94425869e --- /dev/null +++ b/crates/analysis_engine/tests/copied_text_execution_contract.rs @@ -0,0 +1,322 @@ +//! End-to-end contract for cutoff-safe copied-text refusals. + +use analysis_engine::{ + AnalysisEngineError, COPIED_TEXT_ARTIFACT_SCHEMA_VERSION, COPIED_TEXT_MODEL_CONTRACT_VERSION, + COPIED_TEXT_OUTPUT_PROFILE, CopiedTextDocument, MAX_EVIDENCE_UNITS, execute_copied_text_run, +}; +use copied_text::CopiedKind; +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: "copied-text-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-copied-text".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: COPIED_TEXT_MODEL_CONTRACT_VERSION.into(), + output_profile: COPIED_TEXT_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-copied-text", "accepted", &request.idempotency_key) + .expect("accepted") +} + +fn document(document_id: &str, kind: CopiedKind, available_time: &str) -> CopiedTextDocument { + CopiedTextDocument::new( + document_id, + kind, + "snapshot-copied-text", + available(available_time), + ) + .expect("document") +} + +fn mixed_documents() -> Vec { + vec![ + document( + "unique-a", + CopiedKind::UniqueContent, + "2026-07-31T22:00:00Z", + ), + document( + "copied-b", + CopiedKind::CopiedText, + "2026-07-31T23:00:00Z", + ), + document( + "copied-c", + CopiedKind::CopiedText, + "2026-08-01T00:00:00Z", + ), + ] +} + +fn execute( + request: &AnalysisRunRequest, + documents: &[CopiedTextDocument], +) -> Result { + execute_copied_text_run( + request, + &accepted(request), + "snapshot-copied-text", + cutoff(), + documents, + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn mixed_copied_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, + COPIED_TEXT_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.document_count, 3); + assert_eq!(execution.artifact.unique_content_count, 1); + assert_eq!(execution.artifact.copied_text_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, + "copied_text_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(COPIED_TEXT_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_copied_text_run( + &equivalent, + &accepted(&equivalent), + "snapshot-copied-text", + 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", + CopiedKind::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( + CopiedTextDocument::new( + "other-snapshot", + CopiedKind::UniqueContent, + "snapshot-other", + available("2026-07-31T23:30:00Z"), + ) + .expect("cross-snapshot document"), + ); + assert_eq!( + execute(&request, &documents), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn raw_corpus_limit_is_checked_before_identity_allocation() { + let request = request(); + let repeated = document( + "same", + CopiedKind::UniqueContent, + "2026-07-31T23:00:00Z", + ); + let exact_limit = vec![repeated.clone(); MAX_EVIDENCE_UNITS]; + assert_eq!( + execute(&request, &exact_limit), + Err(AnalysisEngineError::DuplicateEvidence) + ); + let over_limit = vec![repeated; MAX_EVIDENCE_UNITS + 1]; + assert_eq!( + execute(&request, &over_limit), + Err(AnalysisEngineError::LimitExceeded) + ); +} + +#[test] +fn empty_unique_only_copied_only_and_duplicate_identities_fail_closed() { + let request = request(); + assert_eq!( + execute(&request, &[]), + Err(AnalysisEngineError::InvalidEvidence) + ); + let unique_only = vec![ + document( + "unique-a", + CopiedKind::UniqueContent, + "2026-07-31T22:00:00Z", + ), + document( + "unique-b", + CopiedKind::UniqueContent, + "2026-07-31T23:00:00Z", + ), + ]; + assert_eq!( + execute(&request, &unique_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let copied_only = vec![ + document( + "copied-a", + CopiedKind::CopiedText, + "2026-07-31T22:00:00Z", + ), + document( + "copied-b", + CopiedKind::CopiedText, + "2026-07-31T23:00:00Z", + ), + ]; + assert_eq!( + execute(&request, &copied_only), + Err(AnalysisEngineError::InvalidEvidence) + ); + let duplicates = vec![ + document( + "same", + CopiedKind::UniqueContent, + "2026-07-31T22:00:00Z", + ), + document( + "same", + CopiedKind::CopiedText, + "2026-07-31T23:00:00Z", + ), + ]; + assert_eq!( + execute(&request, &duplicates), + Err(AnalysisEngineError::DuplicateEvidence) + ); + assert_eq!( + CopiedTextDocument::new( + "", + CopiedKind::UniqueContent, + "snapshot-copied-text", + available("2026-07-31T23:00:00Z"), + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + let documents = mixed_documents(); + assert_eq!( + execute_copied_text_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_copied_text_run( + &mismatched, + &accepted(&mismatched), + "snapshot-copied-text", + 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", + "modality_source_v1", + "corpus_background_v1", + "citation_edge_v1", + "lineage_criterion_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_copied_text_run( + &reused, + &accepted(&reused), + "snapshot-copied-text", + cutoff(), + &documents, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..eb11dd671 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 | +| copied-text analysis-run profile | ADR 0004/0012/0022/0065; CopiedKind CopiedText/UniqueContent | `analysis_engine` `copied_text_v1` binds `refuse_copied_text_as_unique_content` and `refuse_copied_text_as_stopword_deletion`; digest-bound refusals, not `identity_recovery_rate` inspect metric, not template-copy identity, 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/0065-copied-text-analysis-run.md b/docs/adr/0065-copied-text-analysis-run.md new file mode 100644 index 000000000..c268fe027 --- /dev/null +++ b/docs/adr/0065-copied-text-analysis-run.md @@ -0,0 +1,66 @@ +# ADR 0065 — Copied-text residue refusals as an analysis-run output profile + +**Decision status:** Proposed +**Implementation maturity:** active-PR — not implemented-main +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0004/0012 (copied-text residue is not unique content) and ADR 0022 (cutoff-safe analysis-run execution). Does not reuse ADR 0064 (citation-edge provenance-is-not-transition), ADR 0063 (lineage-criterion fitting), ADR 0062 (corpus-background), ADR 0061 (modality-source), ADR 0060 (prompt-source), ADR 0059 (style-source), ADR 0058 (copy-identity / template-copy), 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 copied-text residue as unique latent content or as stopword deletion via `copied_text::refuse_copied_text_as_unique_content` and `refuse_copied_text_as_stopword_deletion`. Operators still cannot request that refusal census as a digest-bound analysis-run output. + +The first branch implementation called this profile cutoff-safe while `CopiedTextDocument` carried only a document identity and `CopiedKind`. It therefore could not prove when a row became available or which immutable snapshot supplied it. A future row could participate in duplicate detection and counts for an earlier historical replay, and an unrelated snapshot could be attributed to the requested snapshot. The executor also compared RFC 3339 cutoff strings rather than temporal instants and allocated duplicate-tracking state before enforcing the shared analysis population bound. + +`identity_recovery_rate` stays library-side. This profile does not put a `scientific_acceptance` metric on inspect payloads. GPU kernels, MCMC, and topic birth/split/merge remain outside this decision. + +## Decision + +Add the `copied_text_v1` analysis-run output profile to `analysis_engine` as an active PR implementation. The executor: + +- requires every `CopiedTextDocument` to carry an immutable `snapshot_id` and explicit `AvailableTime` rather than inventing provenance in a compatibility constructor; +- binds request and executor cutoffs by parsed `KnowledgeCutoff::instant()` equality, so equivalent RFC 3339 spellings of one instant are equivalent; +- rejects cross-snapshot rows as provenance violations; +- excludes same-snapshot rows whose `AvailableTime` is later than the knowledge cutoff before duplicate or domain admission, so future evidence cannot change an earlier historical replay; +- preserves fail-closed duplicate detection among rows actually visible at the cutoff; +- applies `MAX_EVIDENCE_UNITS` to the raw document slice before allocating duplicate-tracking state, and derives `document_count` from cutoff-admitted identities; +- invokes the existing copied-text domain refusals without copying their vocabulary and accepts only the expected provider results; unexpected provider success or another present/future error fails closed; +- emits a canonical SHA-256-digested `tepp.copied_text.v1` artifact with unique-content/copied-text counts, matching refusal counts, and inference status `copied_text_is_not_unique_content_not_stopword_deletion`; +- keeps terminal provider validation state as `validated`, separate from the domain inference claim; +- retains the 256 KiB untrusted-input cap. Bounded identifiers, timestamps, and census counts make valid canonical output smaller than that cap, so no second post-validation output-size branch is needed. + +## Evidence and alternatives + +RED commit `1d2c67f4b41f7788329e21ee4b1ef91bd3c30039` adds contracts for equivalent cutoff instants, explicit availability/snapshot provenance, future-duplicate replay invariance, cross-snapshot refusal, provider/domain status separation, and exact-limit versus limit-plus-one population admission. The causal repair is the ordinary forward successor on this branch; predecessor checks do not transfer. + +Alternatives considered: + +1. Keep document provenance implicit in the run request — rejected because a run-level snapshot/cutoff cannot prove each supplied row came from that snapshot or was available at that cutoff. +2. Reject all post-cutoff rows — rejected for historical replay because adding evidence that did not yet exist must not turn an earlier valid run into a failure. Same-snapshot future rows are censored before duplicate/domain admission; cross-snapshot rows remain admission errors. +3. Compare RFC 3339 strings — rejected because multiple legal textual representations can denote one instant. +4. Duplicate copy-identity or citation-edge vocabulary — rejected because those profiles own different domain semantics. This adapter consumes the protected-main `copied_text` contract. +5. Put `identity_recovery_rate` on the operator artifact — rejected because inspect payloads remain metric-free and numerical/scientific acceptance is a separate boundary. + +## Consequences + +A historical copied-text census is invariant to same-snapshot evidence that becomes available after its knowledge cutoff. Visible duplicates still fail closed, cross-snapshot evidence cannot be silently relabelled, and raw input is bounded before identity allocation. Provider validation and domain inference remain separate claims. + +The implementation remains branch-local and therefore this ADR stays `Proposed`. Consolidation into the surviving Analysis Run vehicle must preserve the source, tests, refusal semantics, temporal/provenance invariants, doctoring, and traceability before this branch can be treated as superseded. + +## Verification + +Run on the unchanged surviving head: + +```text +cargo fmt --all -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +python3 scripts/validate_documentation.py +``` + +Exact-head required workflows, owned production line/branch coverage, current review findings, and qualifying independent approval remain separate merge gates. + +## Rollback and supersession + +Rollback removes the `copied_text_v1` profile. No persisted schema migration is introduced. Supersede only after a verified successor inherits the explicit snapshot/availability provenance, instant-based cutoff binding, historical replay invariant, raw population bound, fail-closed provider contract, and copied-text/domain distinction. Simple Close without verified inheritance is not supersession. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..60ba1b8c7 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. | +| [0065](0065-copied-text-analysis-run.md) | Copied-text residue refusals as an analysis-run profile | Accepted | active-PR | Complements ADR 0004/0012/0022; `refuse_copied_text_as_unique_content` + `refuse_copied_text_as_stopword_deletion`, not template-copy identity. | | [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. +- **copied-text analysis-run profile:** ADR 0065. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/copied-text-analysis-run.md b/docs/doctoring/copied-text-analysis-run.md new file mode 100644 index 000000000..4e10c20e5 --- /dev/null +++ b/docs/doctoring/copied-text-analysis-run.md @@ -0,0 +1,14 @@ +# Copied-text analysis-run composition + +**Active slice:** ADR 0065 / `copied_text_v1` +**Implementation maturity:** active-PR (not implemented-main) + +`copied_text` already refuses to treat copied-text residue as unique latent content or as stopword deletion. This slice binds those refusals to a digest-bound Analysis Run profile without copying the domain vocabulary. + +Every input row now carries immutable `snapshot_id` provenance and explicit `AvailableTime`. The executor compares request and execution cutoffs as parsed temporal instants, rejects cross-snapshot rows, and excludes same-snapshot rows that were unavailable at the requested cutoff before duplicate or domain admission. A future row that reuses an otherwise visible identity therefore cannot change an earlier historical replay; duplicate identities among evidence actually visible at the cutoff still fail closed. + +The raw input population is bounded by `MAX_EVIDENCE_UNITS` before duplicate-tracking allocation, and `document_count` is the cutoff-admitted identity count rather than the raw slice length. The artifact retains inference status `copied_text_is_not_unique_content_not_stopword_deletion`, while the terminal provider validation status is separately `validated`. + +The 256 KiB wire limit remains an untrusted-input admission boundary. Valid artifact identifiers, strict timestamps, and census counts are bounded, including worst-case JSON identifier escaping, so canonical output stays below that limit without a second post-validation egress check. + +`identity_recovery_rate` stays library-side. This is not template-copy identity, citation-edge provenance, corpus-background, modality-source, prompt-source, style-source, simulation method-effect estimation, GPU, MCMC, or topic birth/split/merge.