From 1ebb6266b2e9d2a7eee306e3b1d161fa05d0422a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 31 Aug 2026 19:12:16 +0000 Subject: [PATCH 01/12] feat(analysis): bind exhaustive case-deletion refit to an analysis-run profile GAP-004 leftover / ADR 0056. Bind existing fit_exhaustive_case_deletion to cutoff-safe case_deletion_refit_v1. Actual D\{i} fits; reweighting and a fixed posterior cannot substitute. Not a Bayesian sampler and not implemented-main. --- CHANGELOG.md | 2 + .../src/case_deletion_refit_artifact.rs | 366 ++++++++++++++++++ crates/analysis_engine/src/lib.rs | 26 +- .../case_deletion_refit_execution_contract.rs | 219 +++++++++++ docs/TRACEABILITY.md | 1 + .../0056-case-deletion-refit-analysis-run.md | 87 +++++ docs/adr/README.md | 2 + .../case-deletion-refit-analysis-run.md | 17 + 8 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 crates/analysis_engine/src/case_deletion_refit_artifact.rs create mode 100644 crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs create mode 100644 docs/adr/0056-case-deletion-refit-analysis-run.md create mode 100644 docs/doctoring/case-deletion-refit-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..21aedcda5 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] +- **Exhaustive case-deletion analysis-run profile**: cutoff-safe `case_deletion_refit_v1` binds `fit_exhaustive_case_deletion` and refuses reweighting or a fixed posterior as a substitute for an actual deleted-data fit (`analysis_engine`). Not a Bayesian sampler and not implemented-main. + - `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/analysis_engine/src/case_deletion_refit_artifact.rs b/crates/analysis_engine/src/case_deletion_refit_artifact.rs new file mode 100644 index 000000000..eefee2611 --- /dev/null +++ b/crates/analysis_engine/src/case_deletion_refit_artifact.rs @@ -0,0 +1,366 @@ +//! Digest-bound exhaustive case-deletion refit as an analysis-run profile. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::KnowledgeCutoff; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{ + AnalysisEngineError, CaseDeletionDocument, CaseDeletionRefitter, ExhaustiveCaseDeletionError, + fit_exhaustive_case_deletion, format_digest, require_receipt_identity, valid_identifier, +}; + +/// Versioned schema for a completed exhaustive case-deletion artifact. +pub const CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION: &str = "tepp.case_deletion_refit.v1"; +/// Model contract required by the exhaustive case-deletion execution path. +pub const CASE_DELETION_REFIT_MODEL_CONTRACT_VERSION: &str = "case_deletion_refit_v1"; +/// Analysis-run output profile required for an exhaustive case-deletion artifact. +pub const CASE_DELETION_REFIT_OUTPUT_PROFILE: &str = "case_deletion_refit_v1"; +/// Maximum canonical artifact JSON size. +pub const CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const CASE_DELETION_REFIT_INFERENCE_STATUS: &str = + "exhaustive_actual_deletion_not_reweighting_approx"; + +/// Cutoff-safe exhaustive case-deletion payload bound to an existing fitter. +#[derive(Clone, Debug)] +pub struct CaseDeletionRefitInput<'a, D, F> { + documents: &'a [CaseDeletionDocument], + seed_domain_base: &'a str, + fitter: &'a F, +} + +impl<'a, D, F> CaseDeletionRefitInput<'a, D, F> { + /// Construct a case-deletion payload from existing runner inputs. + #[must_use] + pub const fn new( + documents: &'a [CaseDeletionDocument], + seed_domain_base: &'a str, + fitter: &'a F, + ) -> Self { + Self { + documents, + seed_domain_base, + fitter, + } + } + + /// Borrow the admitted documents. + #[must_use] + pub const fn documents(&self) -> &'a [CaseDeletionDocument] { + self.documents + } + + /// Return the seed-domain base used to separate full and deleted fits. + #[must_use] + pub const fn seed_domain_base(&self) -> &'a str { + self.seed_domain_base + } + + /// Borrow the scientific fitter invoked on each actual corpus. + #[must_use] + pub const fn fitter(&self) -> &'a F { + self.fitter + } +} + +/// Completed, bounded exhaustive case-deletion counts for analysis-run clients. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CaseDeletionRefitArtifact { + /// 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 by the run. + pub knowledge_cutoff: String, + /// Number of admitted documents. + pub document_count: u64, + /// Number of actual one-document deletion refits. + pub deletion_refit_count: u64, + /// Number of independent seed domains (full fit plus each deletion). + pub independent_seed_domain_count: u64, + /// Domain-separated randomness identity for the full-data fit. + pub full_seed_domain: String, + /// Fixed claim boundary for consumer copy. + pub inference_status: String, +} + +impl CaseDeletionRefitArtifact { + /// Parse and fully validate a bounded artifact JSON payload. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::InvalidCaseDeletionRefitArtifact`] when + /// the schema, identifiers, counts, or claim boundary fail. + pub fn from_json(payload: &str) -> Result { + if payload.len() > CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact: Self = serde_json::from_str(payload) + .map_err(|_| AnalysisEngineError::InvalidCaseDeletionRefitArtifact)?; + 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() > CASE_DELETION_REFIT_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> { + if self.schema_version != CASE_DELETION_REFIT_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.deletion_refit_count != self.document_count + || self.independent_seed_domain_count + != self + .document_count + .checked_add(1) + .ok_or(AnalysisEngineError::InvalidCaseDeletionRefitArtifact)? + || !valid_identifier(&self.full_seed_domain) + || self.inference_status != CASE_DELETION_REFIT_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidCaseDeletionRefitArtifact); + } + Ok(()) + } +} + +/// One completed exhaustive case-deletion artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct CaseDeletionRefitExecution { + /// Digest-bound completed case-deletion artifact. + pub artifact: CaseDeletionRefitArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute exhaustive actual case-deletion as one analysis-run profile. +/// +/// The executor invokes [`fit_exhaustive_case_deletion`] and does not +/// reimplement leave-one-out fitting, reweighting, or a diagonal +/// approximation. Raw posteriors stay with the scientific fitter; the +/// operator artifact carries only bounded counts and seed-domain identity. +/// This is not a Bayesian sampler and not GPU execution. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error, invalid corpus, +/// fitter refusal, or invalid artifact error. +pub fn execute_case_deletion_refit_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + input: &CaseDeletionRefitInput<'_, D, F>, + completed_at: impl Into, +) -> Result +where + F: CaseDeletionRefitter, +{ + request.to_json()?; + accepted.to_json()?; + require_receipt_identity(request, accepted)?; + if request.snapshot_id != snapshot_id { + return Err(AnalysisEngineError::SnapshotMismatch); + } + if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + || request.model_contract_version != CASE_DELETION_REFIT_MODEL_CONTRACT_VERSION + || request.output_profile != CASE_DELETION_REFIT_OUTPUT_PROFILE + || !valid_identifier(input.seed_domain_base()) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let fits = + fit_exhaustive_case_deletion(input.documents(), input.seed_domain_base(), input.fitter()) + .map_err(|error| match error { + ExhaustiveCaseDeletionError::InvalidInput => AnalysisEngineError::InvalidEvidence, + ExhaustiveCaseDeletionError::Fit(_) => AnalysisEngineError::CaseDeletionFitFailure, + })?; + let document_count = u64::try_from(input.documents().len()) + .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let deletion_refit_count = u64::try_from(fits.deletion_refits.len()) + .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let independent_seed_domain_count = document_count + .checked_add(1) + .ok_or(AnalysisEngineError::ArithmeticOverflow)?; + let artifact = CaseDeletionRefitArtifact { + schema_version: CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION.into(), + run_id: accepted.run_id.clone(), + snapshot_id: snapshot_id.to_owned(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + document_count, + deletion_refit_count, + independent_seed_domain_count, + full_seed_domain: fits.full_seed_domain, + inference_status: CASE_DELETION_REFIT_INFERENCE_STATUS.into(), + }; + let digest = artifact.sha256()?; + let summary = AnalysisResultSummary::new( + "case_deletion_refit", + document_count, + 3, + CASE_DELETION_REFIT_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("case_deletion_refit_artifact_{}", &digest[..16]), + digest, + CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(CaseDeletionRefitExecution { + artifact, + terminal_result, + }) +} + +#[cfg(test)] +mod tests { + use super::{ + CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT, CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION, + CASE_DELETION_REFIT_INFERENCE_STATUS, CaseDeletionRefitArtifact, CaseDeletionRefitInput, + }; + use crate::{AnalysisEngineError, CaseDeletionDocument}; + + struct UnusedFitter; + + fn artifact() -> CaseDeletionRefitArtifact { + CaseDeletionRefitArtifact { + schema_version: CASE_DELETION_REFIT_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, + deletion_refit_count: 3, + independent_seed_domain_count: 4, + full_seed_domain: "topic-model-run:full".into(), + inference_status: CASE_DELETION_REFIT_INFERENCE_STATUS.into(), + } + } + + fn assert_invalid(artifact: &CaseDeletionRefitArtifact) { + assert_eq!( + artifact.to_json(), + Err(AnalysisEngineError::InvalidCaseDeletionRefitArtifact) + ); + } + + #[test] + fn artifact_round_trip_and_size_bounds_fail_closed() { + let artifact = artifact(); + let payload = artifact.to_json().expect("json"); + assert_eq!( + CaseDeletionRefitArtifact::from_json(&payload), + Ok(artifact.clone()) + ); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + assert_eq!( + CaseDeletionRefitArtifact::from_json("{}"), + Err(AnalysisEngineError::InvalidCaseDeletionRefitArtifact) + ); + assert_eq!( + CaseDeletionRefitArtifact::from_json( + &"x".repeat(CASE_DELETION_REFIT_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.deletion_refit_count = 2; + value + }, + { + let mut value = artifact.clone(); + value.independent_seed_domain_count = 3; + value + }, + { + let mut value = artifact.clone(); + value.full_seed_domain.clear(); + value + }, + { + let mut value = artifact.clone(); + value.inference_status.clear(); + value + }, + ]; + for invalid in invalid_artifacts { + assert_invalid(&invalid); + } + } + + #[test] + fn input_accessors_expose_documents_and_seed_base() { + let documents = [CaseDeletionDocument { + document_id: "document-a".into(), + evidence: 1.0, + }]; + let fitter = UnusedFitter; + let input = CaseDeletionRefitInput::new(&documents, "topic-model-run", &fitter); + assert_eq!(input.documents(), &documents); + assert_eq!(input.seed_domain_base(), "topic-model-run"); + let _ = input.fitter(); + } +} diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..b14edb352 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -8,9 +8,12 @@ //! through [`tepp_api`]. It deliberately does not claim latent-variable or topic //! estimation authority; those estimators remain separate scientific crates. //! estimation authority; it invokes estimators through their scientific crate -//! contracts and preserves their artifact meaning. +//! contracts and preserves their artifact meaning. Exhaustive case-deletion +//! is invoked through [`fit_exhaustive_case_deletion`] and is not a +//! reweighting approximation or a Bayesian sampler. mod case_deletion_refit; +mod case_deletion_refit_artifact; mod lineage_criterion; mod topic_context_posterior; mod topic_lineage_artifact; @@ -41,6 +44,13 @@ 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; +/// Exhaustive case-deletion artifact and execution contracts from this engine. +pub use case_deletion_refit_artifact::{ + CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT, CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION, + CASE_DELETION_REFIT_MODEL_CONTRACT_VERSION, CASE_DELETION_REFIT_OUTPUT_PROFILE, + CaseDeletionRefitArtifact, CaseDeletionRefitExecution, CaseDeletionRefitInput, + execute_case_deletion_refit_run, +}; /// Rust-owned independent TDT link-criterion posterior fitting contracts. pub use lineage_criterion::{ LineageCriterionFit, LineageCriterionFitError, LineageCriterionObservation, @@ -248,6 +258,10 @@ pub enum AnalysisEngineError { TopicMeasurement(TopicMeasurementError), /// A topic-lineage artifact violated its bounded schema or count invariants. InvalidTopicLineageArtifact, + /// A case-deletion artifact violated its bounded schema or counts. + InvalidCaseDeletionRefitArtifact, + /// The scientific fitter refused a full or actual deleted-data corpus. + CaseDeletionFitFailure, } impl fmt::Display for AnalysisEngineError { @@ -262,6 +276,8 @@ 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::InvalidCaseDeletionRefitArtifact => "invalid case-deletion refit artifact", + Self::CaseDeletionFitFailure => "case-deletion fitter refused an actual corpus", }; formatter.write_str(message) } @@ -681,6 +697,14 @@ mod tests { AnalysisEngineError::InvalidTopicLineageArtifact, "invalid topic lineage artifact", ), + ( + AnalysisEngineError::InvalidCaseDeletionRefitArtifact, + "invalid case-deletion refit artifact", + ), + ( + AnalysisEngineError::CaseDeletionFitFailure, + "case-deletion fitter refused an actual corpus", + ), ]; for (error, message) in messages { assert_eq!(error.to_string(), message); diff --git a/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs b/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs new file mode 100644 index 000000000..ce18e4d16 --- /dev/null +++ b/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs @@ -0,0 +1,219 @@ +//! End-to-end contract for cutoff-safe exhaustive case-deletion refit. + +use analysis_engine::{ + AnalysisEngineError, CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION, + CASE_DELETION_REFIT_MODEL_CONTRACT_VERSION, CASE_DELETION_REFIT_OUTPUT_PROFILE, + CaseDeletionDocument, CaseDeletionFitContext, CaseDeletionRefitInput, CaseDeletionRefitter, + execute_case_deletion_refit_run, +}; +use temporal_core::KnowledgeCutoff; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +struct MeanFitter; + +struct RefusingFitter; + +impl CaseDeletionRefitter for MeanFitter { + type Error = (); + + fn fit( + &self, + retained_documents: &[&CaseDeletionDocument], + _context: &CaseDeletionFitContext, + ) -> Result { + let sum = retained_documents + .iter() + .map(|document| document.evidence) + .sum::(); + let count = u32::try_from(retained_documents.len()).map_err(|_| ())?; + Ok(sum / f64::from(count)) + } +} + +impl CaseDeletionRefitter for RefusingFitter { + type Error = &'static str; + + fn fit( + &self, + _retained_documents: &[&CaseDeletionDocument], + _context: &CaseDeletionFitContext, + ) -> Result { + Err("synthetic refusal") + } +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn documents() -> Vec> { + vec![ + CaseDeletionDocument { + document_id: "document-a".into(), + evidence: 1.0, + }, + CaseDeletionDocument { + document_id: "document-b".into(), + evidence: 3.0, + }, + CaseDeletionDocument { + document_id: "document-c".into(), + evidence: 8.0, + }, + ] +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "case-deletion-refit-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-case-deletion-refit".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: CASE_DELETION_REFIT_MODEL_CONTRACT_VERSION.into(), + output_profile: CASE_DELETION_REFIT_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-case-deletion-refit", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn execute( + request: &AnalysisRunRequest, +) -> Result { + let documents = documents(); + let fitter = MeanFitter; + execute_case_deletion_refit_run( + request, + &accepted(request), + "snapshot-case-deletion-refit", + cutoff(), + &CaseDeletionRefitInput::new(&documents, "topic-model-run", &fitter), + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn exhaustive_refits_emit_digest_bound_counts_without_reweighting() { + let request = request(); + let execution = execute(&request).expect("execution"); + assert_eq!( + execution.artifact.schema_version, + CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.document_count, 3); + assert_eq!(execution.artifact.deletion_refit_count, 3); + assert_eq!(execution.artifact.independent_seed_domain_count, 4); + assert_eq!(execution.artifact.full_seed_domain, "topic-model-run:full"); + assert_eq!( + execution.artifact.inference_status, + "exhaustive_actual_deletion_not_reweighting_approx" + ); + 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(CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION) + ); +} + +#[test] +fn invalid_corpus_and_fitter_refusal_fail_closed() { + let request = request(); + let one = vec![CaseDeletionDocument { + document_id: "document-a".into(), + evidence: 1.0, + }]; + let fitter = MeanFitter; + assert_eq!( + execute_case_deletion_refit_run( + &request, + &accepted(&request), + "snapshot-case-deletion-refit", + cutoff(), + &CaseDeletionRefitInput::new(&one, "topic-model-run", &fitter), + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + let documents = documents(); + let refusing = RefusingFitter; + assert_eq!( + execute_case_deletion_refit_run( + &request, + &accepted(&request), + "snapshot-case-deletion-refit", + cutoff(), + &CaseDeletionRefitInput::new(&documents, "topic-model-run", &refusing), + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::CaseDeletionFitFailure) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + let documents = documents(); + let fitter = MeanFitter; + assert_eq!( + execute_case_deletion_refit_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + &CaseDeletionRefitInput::new(&documents, "topic-model-run", &fitter), + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); + for invalid_request in [ + { + let mut value = request.clone(); + value.knowledge_cutoff = "2026-08-02T00:00:00Z".into(); + value + }, + { + let mut value = request.clone(); + value.model_contract_version = "other-model".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "composed_fitted_lineage_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "fitted_candidate_k_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "trsl_topic_lineage_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "pareto_candidate_k_v1".into(); + value + }, + ] { + assert_eq!( + execute(&invalid_request), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..27512454d 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -75,6 +75,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `corpus_background` background-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `prompt_source` prompt-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | | candidate K statistical/Pareto gates | ADR 0012; research | `model_selection` fits each candidate `K` with the CPU `f64` reference and scores the actual mixture likelihood plus Schwarz's (1978) `ℓ − (p ln N)/2` penalty before the Pareto gate; candidate blinding, blinded LLM review, GPU, and backend comparison remain accepted-target | active-PR | +| exhaustive case-deletion analysis-run | ADR 0012/0022/0056 | `analysis_engine` `case_deletion_refit_v1` binds `fit_exhaustive_case_deletion`; actual `D \\ {i}` fits; refuses reweighting/fixed-posterior substitutes; not a Bayesian sampler and not implemented-main | active-PR | | compositional topic correlation / stable clustering | ADR 0005/0012; research | future `network_analysis` | accepted-target | | posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | `psychometric_core` construct/input gates, true-loading OLS recovery, posterior-draw point-estimate averaging, Rubin `T` on draw-level OLS loadings, CWC within/between OLS plus the contextual effect, event-time log-rate, constant- and time-varying-predictor discrete effects (Voelkle Eqs. 12 and 14), exact scalar discrete process noise (Driver et al., 2017, Eq. 3), lagged latent covariance and unconditional latent variance (Driver et al., 2017, Eq. 3–4), stationary within-subject variance (Driver et al., 2017, Eq. 4 as `Δt → ∞`; `asymDIFFUSION`), trait-plus-state variance (Driver et al., 2017, §4.3 `TRAITVAR`; not process noise), observed-indicator variance and lagged observed covariance (Driver et al., 2017, Eq. 5; Table 2 `MANIFESTVAR` is `Θ`, not `Var(y)`; `MANIFESTTRAITVAR` is not `MANIFESTVAR`; `Θ` does not enter lagged observed covariance; observed-indicator mean is `τ + λ μ`; `MANIFESTMEANS` is not `E(y)`; `CINT` is not `MANIFESTMEANS`; discrete latent mean is `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`; `T0MEANS` is not `μ_t`; evolved observed mean is `τ + λ μ_t`; `τ + λ μ_0` is not `E(y_t)`; contemporaneous `TDPREDEFFECT` impulse is `m x`, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that contemporaneous impulse is `τ + λ(μ_t + m x)`, and `τ + λ μ_t` is not that observed mean; time-independent `TIPREDEFFECT` increment is `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, not `M x`, not Voelkle Eq. 14, and not the coefficient `B`; Eq. 5 of that increment is `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; within-interval `TDPREDEFFECT` carry is `e^{A(t−u)} M x` for `t0 < u < t`, not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that carry is `τ + λ(μ_t + e^{a(t−u)} m x)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that carried observed mean when `u ≠ t`; §7.2 level-change `CINT` is `κ = −a m x` (`a < 0`; not the dissipating Dirac, not a free `CINT`, not `TIPREDEFFECT`; Eq. 3 of that setting is `(1 − e^{a Δt}) m x`); §7.2 extra-process contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, not the dissipating Dirac; `ε ≥ 0` fails closed; Eq. 5 of that contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`; extra `LAMBDA` is 0; `τ + λ μ_t` is not that observed mean; after-t0 extra-process `TDPREDEFFECT` uses `t − u` with `t0 < u < t` while `μ_t` uses `Δt`; that after-t0 observed mean is not the first-occasion extra-process observed mean; §7.2 `asymTIPREDEFFECT` is `-B z / a` for `a < 0` and is not `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`; §7.2 `addedTIPREDVAR` is `(B / a)² v` and is not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`; Table 2 `asymCINT` is `-κ / a` for `a < 0` and is not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`; p. 16 stationary `T0MEANS` is `-κ / a + −B z / a` and is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean; Eq. 5 of that constrained mean is `τ + λ(−κ / a + −B z / a)`; `τ + λ μ_0` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`; stationary `T0VAR` is `trait + −q / (2 a) + (B / a)² v` (not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Eq. 5 of that constrained variance is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`)); lagged stationary `T0VAR` is `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (trait and `addedTIPREDVAR` do not decay; contemporaneous `T0VAR` is not that lagged map; decaying the constrained total as if it were all state is not that lagged map; Eq. 5 of that lagged covariance is `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`; `Θ` does not enter; contemporaneous `Var(y_0)` is not that lagged observed covariance; the lagged latent covariance is not that observed covariance); later-occasion stationary `T0VAR` is `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`; evolving the constrained total as if it were all state is not that later map; the lagged covariance omits `Q_Δt`; `Q_Δt` is not that later map; Eq. 5 of that later-occasion variance is `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`; lagged observed covariance omits `Q_Δt` and `θ`; `MANIFESTVAR` is not `Var(y_t)`; the later-occasion latent variance is not `Var(y_t)`); predetermined later-occasion `T0VAR` is `trait + e^{2 a Δt} p_0 + Q_Δt + (B / a)² v` (free `T0VAR` `p_0` is not that later map; setting `p_0 = −q / (2 a)` recovers the stationary later-occasion map; stationary later variance uses `−q / (2 a)` in place of `p_0` and is not that later map when `p_0` is free; evolving `trait + p_0 + (B / a)² v` as if it were all state is not that later map; Eq. 5 of that predetermined later-occasion variance is `λ²(trait + e^{2 a Δt} p_0 + Q_Δt + (B / a)² v) + θ + ψ`; `MANIFESTVAR` is not `Var(y_t)`; the predetermined later-occasion latent variance is not `Var(y_t)`; stationary later observed variance is not that observed variance when `p_0` is free); predetermined lagged `T0VAR` is `trait + e^{a Δt} p_0 + (B / a)² v` (free `T0VAR` `p_0` is not that lagged map; setting `p_0 = −q / (2 a)` recovers the stationary lagged map; stationary lagged covariance uses `−q / (2 a)` in place of `p_0` and is not that lagged map when `p_0` is free; evolving `trait + p_0 + (B / a)² v` as if it were all state is not that lagged map; later-occasion variance includes `Q_Δt` and is not that lagged map; Eq. 5 of that predetermined lagged covariance is `λ²(trait + e^{a Δt} p_0 + (B / a)² v) + ψ`; `MANIFESTVAR` does not enter; the predetermined lagged latent covariance is not that observed covariance; predetermined later observed variance includes `Q_Δt` and `θ` and is not that lagged observed covariance; stationary lagged observed covariance is not that observed covariance when `p_0` is free; the predetermined first-occasion variance of §4.3 predetermined `T0VAR` is `trait + p_0 + (B / a)² v`; free `p_0` is not that map; stationary first-occasion variance uses `−q / (2 a)` in place of `p_0` and is not that map when `p_0` is free; lagged covariance decays the state and is not that map; later-occasion variance includes `Q_Δt` and is not that map; Eq. 5 of that predetermined first-occasion variance is `λ²(trait + p_0 + (B / a)² v) + θ + ψ`; `MANIFESTVAR` is not that first-occasion observed variance; the predetermined first-occasion latent variance is not that observed variance; stationary first-occasion observed variance is not that observed variance when `p_0` is free; predetermined later observed variance includes `Q_Δt` and is not that first-occasion observed variance; later-start lagged covariance of predetermined `T0VAR` is `trait + e^{a s}(e^{2 a u} p_0 + Q_u) + (B / a)² v` (Driver et al., 2017, §4.3 `startoffset`; Eq. 4; JSS PDF re-opened 2026-08-23T10:27Z; first-occasion lagged omits `e^{a s} Q_u`; later-occasion variance does not lag; stationary lagged uses `−q / (2 a)`; decaying the later total is not that map; Eq. 5 of that later-start lagged covariance is `λ²` of it plus `ψ`; `Θ` does not enter; first-occasion lagged observed omits `e^{a s} Q_u`; later observed variance includes `Q_u` and `θ`; later-start later-occasion variance of predetermined `T0VAR` is `trait + e^{2 a s}(e^{2 a u} p_0 + Q_u) + Q_s + (B / a)² v` (Driver et al., 2017, §4.3 `startoffset`; Eq. 3–4 Chapman–Kolmogorov `Q_{u+s} = e^{2 a s} Q_u + Q_s`; JSS PDF re-opened 2026-08-23T11:05Z; later-occasion variance at `u` omits `Q_s`; later-start lagged covariance omits `Q_s`; stationary later uses `−q / (2 a)`; evolving the later total as if it were all state is not that map; ignoring `startoffset` omits `e^{2 a s} Q_u`; Eq. 5 of that later-start later-occasion variance is `λ²` of it plus `θ + ψ`; `MANIFESTVAR` is not that observed variance; p. 16 `discreteDRIFTstd` is `e^{a Δt}` after strictly positive `asymDIFFUSION` `-q / (2 a)` (footnote 4; unstandardised `e^{a Δt}` is defined for growing `a ≥ 0` and for zero diffusion and is not `discreteDRIFTstd`; the §7.1 trait-plus-state autocorrelation uses `TRAITVAR` and is not `discreteDRIFTstd`; p. 16 `discreteDIFFUSIONstd` is `Q_Δt / (−q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` (footnote 4; unstandardised `Q_Δt` is defined for growing `a ≥ 0` and for zero diffusion and is not `discreteDIFFUSIONstd`; the continuous standardisation `−2 a` is not `discreteDIFFUSIONstd`; `Q_Δt / (trait + p + added)` uses `TRAITVAR` and is not `discreteDIFFUSIONstd`; `TRAITVAR` is not the standardisation variance; p. 16 `DIFFUSIONstd` is `q / (−q / (2 a)) = −2 a` after strictly positive `asymDIFFUSION` `-q / (2 a)` (Driver et al., 2017, p. 16; Eq. 4; footnote 4; JSS PDF re-opened 2026-08-23T13:20Z; unstandardised `q` is defined for growing `a ≥ 0` and for zero diffusion and is not `DIFFUSIONstd`; the discrete standardisation `Q_Δt / (−q / (2 a))` depends on `Δt` and is not `DIFFUSIONstd`; `q / (trait + p + added)` uses `TRAITVAR` and is not `DIFFUSIONstd`; `TRAITVAR` is not the standardisation variance; p. 16 `DRIFTstd` is the continuous auto-effect after strictly positive `asymDIFFUSION` `-q / (2 a)` (Driver et al., 2017, p. 16; Eq. 1; footnote 4; JSS PDF re-opened 2026-08-23T13:28Z); unstandardised `a` is defined for growing `a ≥ 0` and for zero diffusion and is not `DRIFTstd`; the discrete standardisation `e^{a Δt}` depends on the event interval and is not `DRIFTstd`; `a p / (trait + p + added)` uses `TRAITVAR` and is not `DRIFTstd`; `TRAITVAR` is not the standardisation variance); p. 16 `asymTIPREDEFFECTstd` is `(-B / a) · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` and strictly positive predictor variance `v` (Driver et al., 2017, p. 16; §7.2; footnote 4; JSS PDF re-opened 2026-08-23T14:25Z; unstandardised `-B / a` is defined for a zero coefficient and for zero predictor variance and is not `asymTIPREDEFFECTstd`; the finite-interval standardisation `A^{-1}[e^{A Δt} − I] B · √v / √p` depends on the event interval and is not `asymTIPREDEFFECTstd`; `(-B / a) · √v / √(trait + p + added)` uses `TRAITVAR` and is not `asymTIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); p. 16 `TIPREDEFFECTstd` is `B · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` and strictly positive predictor variance `v` (Driver et al., 2017, p. 16; §7.2; footnote 4; JSS PDF re-opened 2026-08-23T16:21Z; unstandardised `B` is defined for a zero coefficient and for zero predictor variance and is not `TIPREDEFFECTstd`; the asymptotic standardisation `(-B / a) · √v / √p` is the total change and is not `TIPREDEFFECTstd`; the finite-interval standardisation `A^{-1}[e^{A Δt} − I] B · √v / √p` depends on the event interval and is not `TIPREDEFFECTstd`; `B · √v / √(trait + p + added)` uses `TRAITVAR` and is not `TIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); Table 3 `T0TIPREDEFFECTstd` is `t0_b · √v / √p_0` after strictly positive free `T0VAR` `p_0` and strictly positive predictor variance `v` (Driver et al., 2017, Table 3, p. 13; p. 16; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T17:20Z; the affected variance is free `T0VAR`, not `asymDIFFUSION`; unstandardised `t0_b` is defined for a zero coefficient and for zero predictor variance and is not `T0TIPREDEFFECTstd`; `TIPREDEFFECTstd` `B · √v / √(-q / (2 a))` is the continuous coefficient and is not `T0TIPREDEFFECTstd`; `asymTIPREDEFFECTstd` `(-B / a) · √v / √p` is the total change and is not `T0TIPREDEFFECTstd`; `t0_b · √v / √(trait + p_0 + added)` uses `TRAITVAR` and is not `T0TIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); 2017-era `addedT0TIPREDVAR` is `t0_b² v` (Driver et al., 2017, Table 3, p. 13; p. 16; §7.2; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T18:20Z; `T0TIPREDEFFECT %*% TIPREDVAR %*% t(T0TIPREDEFFECT)` immediately after `T0TIPREDEFFECTstd`; form `t0_b` first, then square, then multiply by `v`; a zero coefficient or zero predictor variance is exactly zero; free `T0TIPREDEFFECT` does not require `a < 0`; `(B / a)² v` is `addedTIPREDVAR` and is not this first-occasion map; `t0_b · √v / √p_0` is `T0TIPREDEFFECTstd` and is not this variance; free `T0VAR` is not this extra TI variance; `TRAITVAR` is not this extra TI variance; Equation 5 of 2017-era `addedT0TIPREDVAR` is `λ² t0_b² v` (Driver et al., 2017, Eq. 5, p. 5; Table 3, p. 13; Table 2, p. 12; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T19:10Z; form `t0_b² v` first, then `(λ extra) λ` with `θ = 0`; a zero loading or zero extra is exactly zero; `t0_b² v` is the latent extra, not the observed extra; `λ² p_0 + θ` is first-occasion observed variance, not this extra; `λ² (B / a)² v` is Eq. 5 of `addedTIPREDVAR`, not this first-occasion observed extra; `MANIFESTVAR` `θ` is not this extra; Equation 5 of §7.2 `addedTIPREDVAR` is `λ² (B / a)² v`; form `(B / a)² v` first, then `(λ extra) λ` with `θ = 0`; a zero loading or zero extra is exactly zero; lasting asymptotic extra requires `a < 0`; `(B / a)² v` is the latent extra, not the observed extra; `λ² t0_b² v` is first-occasion extra observed TI variance, not this extra; `λ² p + θ` is stationary observed variance, not this extra; `MANIFESTVAR` `θ` is not this extra; p. 16 `TDPREDEFFECTstd` is `m · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` and strictly positive time-dependent predictor variance; unstandardised `M` is not `TDPREDEFFECTstd`; `TIPREDEFFECTstd` is not `TDPREDEFFECTstd` even when `M = B`; intercept-style `A^{-1}[e^{A Δt} − I] M · √v / √p` is not `TDPREDEFFECTstd`; `m · √v / √(trait + p + added)` uses `TRAITVAR` and is not `TDPREDEFFECTstd`; Table 3 / p. 16 `T0TDPREDEFFECTstd` is `t0_m · √v / √p_0` after strictly positive free `T0VAR` and strictly positive TD predictor variance; unstandardised `t0_m` is not `T0TDPREDEFFECTstd`; `TDPREDEFFECTstd` uses `asymDIFFUSION` and is not `T0TDPREDEFFECTstd`; `T0TIPREDEFFECTstd` is not `T0TDPREDEFFECTstd` even when `t0_m = t0_b`; `t0_m · √v / √(trait + p_0 + added)` uses `TRAITVAR` and is not `T0TDPREDEFFECTstd`; free `T0VAR` does not require `a < 0`; p. 16 `T0VARstd` is `p_0 / p_0 = 1` after strictly positive free `T0VAR` (`solve(sqrt(diag(T0VAR))) %&% T0VAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; default ridge is 0); unstandardised `T0VAR` is not `T0VARstd`; `T0TDPREDEFFECTstd` is not `T0VARstd`; `addedT0TIPREDVAR` is not `T0VARstd`; p. 16 `TRAITVARstd` is `trait / trait = 1` after strictly positive `TRAITVAR` (`solve(sqrt(diag(TRAITVAR))) %&% TRAITVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; no ridge addend); unstandardised `TRAITVAR` is not `TRAITVARstd`; `T0VARstd` is not `TRAITVARstd` even when both equal 1; `addedT0TIPREDVAR` is not `TRAITVARstd`; p. 16 `MANIFESTTRAITVARstd` is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR` (`solve(sqrt(diag(MANIFESTTRAITVAR))) %&% MANIFESTTRAITVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0); unstandardised `MANIFESTTRAITVAR` is not `MANIFESTTRAITVARstd`; `TRAITVARstd` is not `MANIFESTTRAITVARstd` even when both equal 1; `MANIFESTVAR` is not `MANIFESTTRAITVARstd`; p. 16 `MANIFESTVARstd` is `θ / θ = 1` after strictly positive `MANIFESTVAR` (`solve(sqrt(diag(MANIFESTVAR))) %&% MANIFESTVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; 2017-era `dimnames` assignment to `latentNames` is a source bug); unstandardised `MANIFESTVAR` is not `MANIFESTVARstd`; `MANIFESTTRAITVARstd` is not `MANIFESTVARstd` even when both equal 1; Equation 5 `Var(y)` is not `MANIFESTVARstd`; p. 16 `TIPREDVARstd` is `v / v = 1` after strictly positive `TIPREDVAR` (`solve(sqrt(diag(TIPREDVAR))) %&% TIPREDVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; `dimnames` are `TIpredNames`); unstandardised `TIPREDVAR` is not `TIPREDVARstd`; `MANIFESTVARstd` is not `TIPREDVARstd` even when both equal 1; §7.2 `addedTIPREDVAR` is not `TIPREDVARstd`; p. 16 `asymDIFFUSIONstd` is `p / p = 1` after strictly positive `asymDIFFUSION` (`solve(sqrt(diag(asymDIFFUSION))) %&% asymDIFFUSION`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; `dimnames` are `latentNames`); unstandardised `asymDIFFUSION` is not `asymDIFFUSIONstd`; `TIPREDVARstd` is not `asymDIFFUSIONstd` even when both equal 1; `DIFFUSIONstd` `−2 a` is not `asymDIFFUSIONstd`; p. 16 `discreteCINTstd` is `A^{-1}[e^{A Δt} − I] κ / √p` after strictly positive `asymDIFFUSION`; unstandardised `discreteCINT` is not `discreteCINTstd`; `κ / √p` is not `discreteCINTstd`; `(-κ / a) / √p` is not `discreteCINTstd`; `asymCINTstd` is `(-κ / a) / √p` after strictly positive `asymDIFFUSION`; unstandardised `asymCINT` is not `asymCINTstd`; `κ / √p` is not `asymCINTstd`; `discreteCINTstd` is not `asymCINTstd`; `T0MEANSstd` is `μ_0 / √p_0` after strictly positive free `T0VAR`; unstandardised `T0MEANS` is not `T0MEANSstd`; `T0VARstd` is not `T0MEANSstd`; `μ_0 / √asymDIFFUSION` is not `T0MEANSstd`; `MANIFESTMEANSstd` is `τ / √θ` after strictly positive `MANIFESTVAR`; unstandardised `MANIFESTMEANS` is not `MANIFESTMEANSstd`; `MANIFESTVARstd` is not `MANIFESTMEANSstd`; `τ / √(λ² Var(η) + θ)` is not `MANIFESTMEANSstd`; p. 16 `CINTstd` is `κ / √p` after strictly positive `asymDIFFUSION`; unstandardised `CINT` is not `CINTstd`; `asymCINTstd` is not `CINTstd`; `discreteCINTstd` is not `CINTstd`; `κ / √(trait + p + added)` is not `CINTstd`;))))), irregular already-centered residual lag, and strong/strict-gated latent means on the stacked psychometric PR (two-observation residual variance is identically `0` and caps at strong/scalar; Putnick & Bornstein, 2016, PMC5145197 opened 2026-08-19T22:15Z); full ESEM/DSEM remaining | partial | | CPU bounded multithreading + GPU/VRAM streaming/parity | ADR 0001/0006 | future `compute_backend` | accepted-target | diff --git a/docs/adr/0056-case-deletion-refit-analysis-run.md b/docs/adr/0056-case-deletion-refit-analysis-run.md new file mode 100644 index 000000000..8e5e10013 --- /dev/null +++ b/docs/adr/0056-case-deletion-refit-analysis-run.md @@ -0,0 +1,87 @@ +# ADR 0056 — Exhaustive case-deletion refit as an analysis-run output profile + +**Decision status:** Accepted +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-08-31 +**Supersedes:** None; complements ADR 0012 (producer-owned case-deletion influence) and ADR 0022 (cutoff-safe analysis-run execution). +**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 runs the same scientific fitter on the complete corpus +and on every actual `D \ {i}` corpus inside +`analysis_engine::fit_exhaustive_case_deletion`. Operators still cannot +request that runner as a digest-bound analysis-run output. Fitted +candidate-`K` selection, Pareto-front selection, composed fitted-lineage, +and topic activity remain different profiles. Full Bayesian sampling, GPU, +and topic birth/split/merge remain later GAP-004 work and are not this +slice. + +Reweighting, a fixed posterior, or a diagonal approximation must not +replace an actual deleted-data fit. + +## Decision + +Add the `case_deletion_refit_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes already-constructed `CaseDeletionDocument` values, a seed-domain + base, and an existing `CaseDeletionRefitter`; +- requires the request snapshot and knowledge cutoff to match the offered + construction; +- invokes `fit_exhaustive_case_deletion` without reimplementing leave-one-out + fitting; +- emits a canonical SHA-256-digested `tepp.case_deletion_refit.v1` artifact + with document count, deletion-refit count, independent seed-domain count, + the full-fit seed domain, and inference status + `exhaustive_actual_deletion_not_reweighting_approx`; +- keeps raw posteriors with the scientific fitter rather than copying them + onto the operator artifact; +- refuses reuse of `composed_fitted_lineage_v1`, `fitted_candidate_k_v1`, + `pareto_candidate_k_v1`, and `trsl_topic_lineage_v1` as this profile; +- does not invent a Bayesian sampler, persist rows, select GPU backends, or + emit topic birth/split/merge. + +This is exhaustive actual deletion, not reweighting and not a posterior +sampler. + +## Alternatives considered + +1. Bind another fitted candidate-`K` or composed-lineage profile — rejected + because those binds are already live as separate analysis-run profiles. +2. Invent a Bayesian sampler or topic birth/split/merge engine — rejected + because those functions do not exist on protected main. +3. Copy raw posteriors onto the operator artifact — rejected because the + fitter owns posterior meaning and the analysis-run contract stays + identity-free and bounded. +4. Bind the existing exhaustive runner to ADR 0022's analysis-run profile — + accepted. + +## Consequences + +Operators can request cutoff-safe exhaustive actual case-deletion as a +digest-bound terminal result. The artifact does not claim reweighting, +influence diagnostics, Bayesian sampling, GPU parity, or topic +birth/split/merge. Snapshot/profile/cutoff mismatch, invalid corpora, and +fitter refusal fail closed. + +## Verification + +The PR includes Rust unit and integration tests for successful exhaustive +counts, invalid corpora, fitter refusal, snapshot/profile/cutoff mismatch +including reuse of live sibling profiles, 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 +``` + +## Rollback and supersession + +Rollback removes the `case_deletion_refit_v1` profile. No persisted schema +migration is introduced. Supersede only with an ADR that keeps actual +deleted-data fits distinct from reweighting, fixed posteriors, and +Bayesian sampling. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..aef32f101 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,6 +28,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0020](0020-span-grounded-semantic-units.md) | Span-grounded semantic units; language tags are not identity | Accepted | active-PR | First ADR 0004 production slice; concept alignment, invariance, and topic estimation are not claimed. | | [0021](0021-lineageweave-project-history-boundary.md) | LineageWeave project-history service boundary | Accepted | active-PR | Credential-free bounded project-history API preserves LineageWeave authorization ownership. | | [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. | +| [0056](0056-case-deletion-refit-analysis-run.md) | Exhaustive case-deletion as an analysis-run profile | Accepted | active-PR | Complements ADR 0012/0022; actual `D \\ {i}` fits, not reweighting and not a Bayesian sampler. | | [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. | | [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. | @@ -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. +- **exhaustive case-deletion analysis-run claim boundary:** ADR 0056. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/case-deletion-refit-analysis-run.md b/docs/doctoring/case-deletion-refit-analysis-run.md new file mode 100644 index 000000000..24ed1657c --- /dev/null +++ b/docs/doctoring/case-deletion-refit-analysis-run.md @@ -0,0 +1,17 @@ +# Exhaustive case-deletion analysis-run composition + +**Active slice:** ADR 0056 / `case_deletion_refit_v1` +**Protected-main status:** not implemented-main + +`analysis_engine` already fits the complete corpus and every actual +`D \ {i}` corpus through `fit_exhaustive_case_deletion`. This slice binds +that runner to a cutoff-safe analysis-run profile so an operator can +request a digest-bound terminal result. + +The executor refuses reweighting, a fixed posterior, and a diagonal +approximation as substitutes for an actual deleted-data fit. Raw posteriors +stay with the scientific fitter. It is not a Bayesian sampler, not GPU +execution, and not topic birth/split/merge. + +Exact-head Checks and two independent approvals are required before any +implemented-main claim. From a4a92a91851b2fab696562c3533adbbdf740f0e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 10:59:36 +0900 Subject: [PATCH 02/12] test(analysis_engine): cover terminal-result construction failure on the case-deletion-refit profile An invalid completed_at timestamp is the only input that makes AnalysisRunTerminalResult::succeeded fail after execute_* validation. This exercises the previously uncovered '?' at that call site. Co-Authored-By: Claude Fable 5.1 --- .../case_deletion_refit_execution_contract.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs b/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs index ce18e4d16..1b317e1f7 100644 --- a/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs +++ b/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs @@ -7,7 +7,7 @@ use analysis_engine::{ execute_case_deletion_refit_run, }; use temporal_core::KnowledgeCutoff; -use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; struct MeanFitter; @@ -217,3 +217,21 @@ fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { ); } } + +#[test] +fn invalid_completed_at_fails_terminal_result_construction() { + let request = request(); + let documents = documents(); + let fitter = MeanFitter; + assert_eq!( + execute_case_deletion_refit_run( + &request, + &accepted(&request), + "snapshot-case-deletion-refit", + cutoff(), + &CaseDeletionRefitInput::new(&documents, "topic-model-run", &fitter), + "not-a-timestamp", + ), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + ); +} From bc877e152fc9ad239de88f7050fd95854cdefab6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:00:54 +0900 Subject: [PATCH 03/12] test(analysis): expose case-deletion cutoff and resource RED --- .../case_deletion_refit_execution_contract.rs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs b/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs index 1b317e1f7..ee8d14273 100644 --- a/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs +++ b/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs @@ -1,5 +1,7 @@ //! End-to-end contract for cutoff-safe exhaustive case-deletion refit. +use std::sync::atomic::{AtomicUsize, Ordering}; + use analysis_engine::{ AnalysisEngineError, CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION, CASE_DELETION_REFIT_MODEL_CONTRACT_VERSION, CASE_DELETION_REFIT_OUTPUT_PROFILE, @@ -13,6 +15,11 @@ struct MeanFitter; struct RefusingFitter; +#[derive(Default)] +struct CountingFitter { + calls: AtomicUsize, +} + impl CaseDeletionRefitter for MeanFitter { type Error = (); @@ -42,6 +49,19 @@ impl CaseDeletionRefitter for RefusingFitter { } } +impl CaseDeletionRefitter for CountingFitter { + type Error = (); + + fn fit( + &self, + _retained_documents: &[&CaseDeletionDocument], + _context: &CaseDeletionFitContext, + ) -> Result<(), Self::Error> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + fn cutoff() -> KnowledgeCutoff { KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") } @@ -119,6 +139,15 @@ fn exhaustive_refits_emit_digest_bound_counts_without_reweighting() { execution.terminal_result.run_state, AnalysisRunTerminalState::Succeeded ); + assert_eq!( + execution + .terminal_result + .summary + .as_ref() + .expect("summary") + .validation_status, + "validated" + ); assert_eq!( execution.terminal_result.result_sha256.as_deref(), Some(execution.artifact.sha256().expect("digest").as_str()) @@ -129,6 +158,41 @@ fn exhaustive_refits_emit_digest_bound_counts_without_reweighting() { ); } +#[test] +fn equivalent_cutoff_spellings_bind_the_same_instant() { + let canonical_request = request(); + let baseline = execute(&canonical_request).expect("canonical cutoff"); + let mut offset_request = canonical_request; + offset_request.knowledge_cutoff = "2026-08-01T01:00:00+01:00".into(); + let equivalent = execute(&offset_request).expect("equivalent instant"); + assert_eq!(equivalent.artifact, baseline.artifact); + assert_eq!(equivalent.terminal_result.summary, baseline.terminal_result.summary); +} + +#[test] +fn oversized_case_deletion_census_fails_before_any_fitter_call() { + let request = request(); + let documents = (0..257) + .map(|index| CaseDeletionDocument { + document_id: format!("document-{index}"), + evidence: f64::from(index), + }) + .collect::>(); + let fitter = CountingFitter::default(); + assert_eq!( + execute_case_deletion_refit_run( + &request, + &accepted(&request), + "snapshot-case-deletion-refit", + cutoff(), + &CaseDeletionRefitInput::new(&documents, "topic-model-run", &fitter), + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::LimitExceeded) + ); + assert_eq!(fitter.calls.load(Ordering::SeqCst), 0); +} + #[test] fn invalid_corpus_and_fitter_refusal_fail_closed() { let request = request(); From eff6eafcec5d644d3414332bd6ce750c344652bd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:01:56 +0900 Subject: [PATCH 04/12] fix(analysis): admit case-deletion evidence before exhaustive refits --- .../src/case_deletion_refit_artifact.rs | 179 ++++++++++++++---- 1 file changed, 146 insertions(+), 33 deletions(-) diff --git a/crates/analysis_engine/src/case_deletion_refit_artifact.rs b/crates/analysis_engine/src/case_deletion_refit_artifact.rs index eefee2611..2a848d076 100644 --- a/crates/analysis_engine/src/case_deletion_refit_artifact.rs +++ b/crates/analysis_engine/src/case_deletion_refit_artifact.rs @@ -1,15 +1,17 @@ //! Digest-bound exhaustive case-deletion refit as an analysis-run profile. +use corpus_split::cutoff_eligible; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use temporal_core::KnowledgeCutoff; +use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, }; use crate::{ AnalysisEngineError, CaseDeletionDocument, CaseDeletionRefitter, ExhaustiveCaseDeletionError, - fit_exhaustive_case_deletion, format_digest, require_receipt_identity, valid_identifier, + MAX_EVIDENCE_UNITS, fit_exhaustive_case_deletion, format_digest, require_receipt_identity, + valid_identifier, }; /// Versioned schema for a completed exhaustive case-deletion artifact. @@ -18,40 +20,63 @@ pub const CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION: &str = "tepp.case_deletio pub const CASE_DELETION_REFIT_MODEL_CONTRACT_VERSION: &str = "case_deletion_refit_v1"; /// Analysis-run output profile required for an exhaustive case-deletion artifact. pub const CASE_DELETION_REFIT_OUTPUT_PROFILE: &str = "case_deletion_refit_v1"; -/// Maximum canonical artifact JSON size. +/// Maximum accepted case-deletion artifact JSON size. pub const CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; +const MAX_CASE_DELETION_RETAINED_IDENTITIES: usize = 256 * 255; const CASE_DELETION_REFIT_INFERENCE_STATUS: &str = "exhaustive_actual_deletion_not_reweighting_approx"; /// Cutoff-safe exhaustive case-deletion payload bound to an existing fitter. +/// +/// Snapshot and availability provenance remain parallel to the scientific +/// [`CaseDeletionDocument`] so the reusable fitter contract does not acquire +/// application-layer historical-admission fields. #[derive(Clone, Debug)] pub struct CaseDeletionRefitInput<'a, D, F> { documents: &'a [CaseDeletionDocument], + snapshot_ids: &'a [String], + available_times: &'a [AvailableTime], seed_domain_base: &'a str, fitter: &'a F, } impl<'a, D, F> CaseDeletionRefitInput<'a, D, F> { - /// Construct a case-deletion payload from existing runner inputs. + /// Construct a case-deletion payload with explicit per-document provenance. #[must_use] pub const fn new( documents: &'a [CaseDeletionDocument], + snapshot_ids: &'a [String], + available_times: &'a [AvailableTime], seed_domain_base: &'a str, fitter: &'a F, ) -> Self { Self { documents, + snapshot_ids, + available_times, seed_domain_base, fitter, } } - /// Borrow the admitted documents. + /// Borrow the candidate scientific documents. #[must_use] pub const fn documents(&self) -> &'a [CaseDeletionDocument] { self.documents } + /// Borrow immutable source snapshot identities aligned to documents. + #[must_use] + pub const fn snapshot_ids(&self) -> &'a [String] { + self.snapshot_ids + } + + /// Borrow evidence-availability times aligned to documents. + #[must_use] + pub const fn available_times(&self) -> &'a [AvailableTime] { + self.available_times + } + /// Return the seed-domain base used to separate full and deleted fits. #[must_use] pub const fn seed_domain_base(&self) -> &'a str { @@ -95,7 +120,7 @@ impl CaseDeletionRefitArtifact { /// # Errors /// /// Returns [`AnalysisEngineError::InvalidCaseDeletionRefitArtifact`] when - /// the schema, identifiers, counts, or claim boundary fail. + /// the schema, identifiers, counts, resource budget, or claim boundary fail. pub fn from_json(payload: &str) -> Result { if payload.len() > CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT { return Err(AnalysisEngineError::LimitExceeded); @@ -108,17 +133,17 @@ impl CaseDeletionRefitArtifact { /// Serialize canonical validated artifact JSON. /// + /// Valid identifiers, strict timestamp syntax, and the case-deletion + /// resource budget make canonical output smaller than + /// [`CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT`]. Untrusted input remains + /// capped by [`Self::from_json`]. + /// /// # Errors /// - /// Returns a typed validation, serialization, or size failure. + /// Returns a typed validation or serialization failure. pub fn to_json(&self) -> Result { self.validate()?; - let payload = - serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; - if payload.len() > CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT { - return Err(AnalysisEngineError::LimitExceeded); - } - Ok(payload) + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure) } /// Return the lowercase SHA-256 digest of canonical artifact JSON. @@ -132,11 +157,15 @@ impl CaseDeletionRefitArtifact { } fn validate(&self) -> Result<(), AnalysisEngineError> { + let document_count = usize::try_from(self.document_count).ok(); if self.schema_version != CASE_DELETION_REFIT_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 + || document_count.is_none() + || document_count == Some(0) + || document_count == Some(1) + || !within_case_deletion_resource_budget(document_count.unwrap_or_default()) || self.deletion_refit_count != self.document_count || self.independent_seed_domain_count != self @@ -165,14 +194,17 @@ pub struct CaseDeletionRefitExecution { /// /// The executor invokes [`fit_exhaustive_case_deletion`] and does not /// reimplement leave-one-out fitting, reweighting, or a diagonal -/// approximation. Raw posteriors stay with the scientific fitter; the +/// approximation. Evidence availability is admitted before duplicate/scientific +/// fitting, so evidence not yet available at the cutoff cannot alter a +/// historical replay. Raw posteriors stay with the scientific fitter; the /// operator artifact carries only bounded counts and seed-domain identity. /// This is not a Bayesian sampler and not GPU execution. /// /// # Errors /// -/// Returns a request/receipt/snapshot/cutoff/profile error, invalid corpus, -/// fitter refusal, or invalid artifact error. +/// Returns a request/receipt/snapshot/cutoff/profile error, malformed or +/// unavailable provenance, resource limit, fitter refusal, or invalid artifact +/// error. pub fn execute_case_deletion_refit_run( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, @@ -182,6 +214,7 @@ pub fn execute_case_deletion_refit_run( completed_at: impl Into, ) -> Result where + D: Clone, F: CaseDeletionRefitter, { request.to_json()?; @@ -190,21 +223,54 @@ where if request.snapshot_id != snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } - if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + let request_cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if request_cutoff.instant() != knowledge_cutoff.instant() || request.model_contract_version != CASE_DELETION_REFIT_MODEL_CONTRACT_VERSION || request.output_profile != CASE_DELETION_REFIT_OUTPUT_PROFILE + || input.documents().len() != input.snapshot_ids().len() + || input.documents().len() != input.available_times().len() || !valid_identifier(input.seed_domain_base()) { return Err(AnalysisEngineError::InvalidEvidence); } + if input.documents().len() > MAX_EVIDENCE_UNITS { + return Err(AnalysisEngineError::LimitExceeded); + } + + let mut admitted_documents = Vec::with_capacity(input.documents().len().min(256)); + for ((document, document_snapshot_id), available_time) in input + .documents() + .iter() + .zip(input.snapshot_ids()) + .zip(input.available_times()) + { + if document_snapshot_id != snapshot_id { + return Err(AnalysisEngineError::InvalidEvidence); + } + if !cutoff_eligible(available_time, &knowledge_cutoff) { + continue; + } + let next_document_count = admitted_documents + .len() + .checked_add(1) + .ok_or(AnalysisEngineError::LimitExceeded)?; + if !within_case_deletion_resource_budget(next_document_count) { + return Err(AnalysisEngineError::LimitExceeded); + } + admitted_documents.push(document.clone()); + } - let fits = - fit_exhaustive_case_deletion(input.documents(), input.seed_domain_base(), input.fitter()) - .map_err(|error| match error { - ExhaustiveCaseDeletionError::InvalidInput => AnalysisEngineError::InvalidEvidence, - ExhaustiveCaseDeletionError::Fit(_) => AnalysisEngineError::CaseDeletionFitFailure, - })?; - let document_count = u64::try_from(input.documents().len()) + let fits = fit_exhaustive_case_deletion( + &admitted_documents, + input.seed_domain_base(), + input.fitter(), + ) + .map_err(|error| match error { + ExhaustiveCaseDeletionError::InvalidInput => AnalysisEngineError::InvalidEvidence, + ExhaustiveCaseDeletionError::Fit(_) => AnalysisEngineError::CaseDeletionFitFailure, + })?; + let document_count = u64::try_from(admitted_documents.len()) .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; let deletion_refit_count = u64::try_from(fits.deletion_refits.len()) .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; @@ -223,12 +289,7 @@ where inference_status: CASE_DELETION_REFIT_INFERENCE_STATUS.into(), }; let digest = artifact.sha256()?; - let summary = AnalysisResultSummary::new( - "case_deletion_refit", - document_count, - 3, - CASE_DELETION_REFIT_INFERENCE_STATUS, - )?; + let summary = AnalysisResultSummary::new("case_deletion_refit", document_count, 3, "validated")?; let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, @@ -244,13 +305,23 @@ where }) } +fn within_case_deletion_resource_budget(document_count: usize) -> bool { + document_count + .checked_mul(document_count.saturating_sub(1)) + .is_some_and(|retained_identities| { + retained_identities <= MAX_CASE_DELETION_RETAINED_IDENTITIES + }) +} + #[cfg(test)] mod tests { use super::{ CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT, CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION, CASE_DELETION_REFIT_INFERENCE_STATUS, CaseDeletionRefitArtifact, CaseDeletionRefitInput, + within_case_deletion_resource_budget, }; use crate::{AnalysisEngineError, CaseDeletionDocument}; + use temporal_core::AvailableTime; struct UnusedFitter; @@ -296,6 +367,24 @@ mod tests { ); } + #[test] + fn maximal_valid_artifact_stays_below_inbound_wire_cap() { + let identifier = "\\".repeat(256); + let mut artifact = artifact(); + artifact.run_id = identifier.clone(); + artifact.snapshot_id = identifier.clone(); + artifact.full_seed_domain = identifier; + artifact.document_count = 256; + artifact.deletion_refit_count = 256; + artifact.independent_seed_domain_count = 257; + let payload = artifact.to_json().expect("maximal artifact"); + assert!(payload.len() < CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT); + assert_eq!( + CaseDeletionRefitArtifact::from_json(&payload), + Ok(artifact) + ); + } + #[test] fn artifact_metadata_tampering_fails_closed() { let artifact = artifact(); @@ -325,6 +414,13 @@ mod tests { value.document_count = 1; value }, + { + let mut value = artifact.clone(); + value.document_count = 257; + value.deletion_refit_count = 257; + value.independent_seed_domain_count = 258; + value + }, { let mut value = artifact.clone(); value.deletion_refit_count = 2; @@ -352,14 +448,31 @@ mod tests { } #[test] - fn input_accessors_expose_documents_and_seed_base() { + fn resource_budget_matches_quadratic_retained_identity_storage() { + assert!(within_case_deletion_resource_budget(256)); + assert!(!within_case_deletion_resource_budget(257)); + } + + #[test] + fn input_accessors_expose_documents_and_provenance() { let documents = [CaseDeletionDocument { document_id: "document-a".into(), evidence: 1.0, }]; + let snapshot_ids = ["snapshot-1".to_owned()]; + let available_times = + [AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available")]; let fitter = UnusedFitter; - let input = CaseDeletionRefitInput::new(&documents, "topic-model-run", &fitter); + let input = CaseDeletionRefitInput::new( + &documents, + &snapshot_ids, + &available_times, + "topic-model-run", + &fitter, + ); assert_eq!(input.documents(), &documents); + assert_eq!(input.snapshot_ids(), &snapshot_ids); + assert_eq!(input.available_times(), &available_times); assert_eq!(input.seed_domain_base(), "topic-model-run"); let _ = input.fitter(); } From 6d0fb9081334e30dbe062aa11bc594eed801f3e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:02:06 +0900 Subject: [PATCH 05/12] fix(analysis): promote cutoff admission to runtime dependency --- crates/analysis_engine/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/analysis_engine/Cargo.toml b/crates/analysis_engine/Cargo.toml index 7322212b2..89aeaf5f2 100644 --- a/crates/analysis_engine/Cargo.toml +++ b/crates/analysis_engine/Cargo.toml @@ -14,6 +14,7 @@ categories.workspace = true publish = false [dependencies] +corpus_split = { path = "../corpus_split", version = "0.2.0" } event_core = { path = "../event_core", version = "0.2.0" } serde = { workspace = true } serde_json = { workspace = true } @@ -24,7 +25,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" } From 4a85e41519bf806334d2d4f41cde9938d9cd8d6c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:02:42 +0900 Subject: [PATCH 06/12] test(analysis): lock historical case-deletion provenance contracts --- .../case_deletion_refit_execution_contract.rs | 199 ++++++++++++++---- 1 file changed, 163 insertions(+), 36 deletions(-) diff --git a/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs b/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs index ee8d14273..377ff6914 100644 --- a/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs +++ b/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs @@ -8,9 +8,11 @@ use analysis_engine::{ CaseDeletionDocument, CaseDeletionFitContext, CaseDeletionRefitInput, CaseDeletionRefitter, execute_case_deletion_refit_run, }; -use temporal_core::KnowledgeCutoff; +use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; +const SNAPSHOT_ID: &str = "snapshot-case-deletion-refit"; + struct MeanFitter; struct RefusingFitter; @@ -66,6 +68,10 @@ fn cutoff() -> KnowledgeCutoff { KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") } +fn available_time(value: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(value).expect("available time") +} + fn documents() -> Vec> { vec![ CaseDeletionDocument { @@ -83,12 +89,20 @@ fn documents() -> Vec> { ] } +fn visible_snapshot_ids(len: usize) -> Vec { + vec![SNAPSHOT_ID.to_owned(); len] +} + +fn visible_available_times(len: usize) -> Vec { + vec![available_time("2026-07-01T00:00:00Z"); len] +} + fn request() -> AnalysisRunRequest { AnalysisRunRequest { contract_version: 1, idempotency_key: "case-deletion-refit-idem".into(), tenant_workspace_id: "tenant-workspace".into(), - snapshot_id: "snapshot-case-deletion-refit".into(), + snapshot_id: SNAPSHOT_ID.into(), knowledge_cutoff: "2026-08-01T00:00:00Z".into(), model_contract_version: CASE_DELETION_REFIT_MODEL_CONTRACT_VERSION.into(), output_profile: CASE_DELETION_REFIT_OUTPUT_PROFILE.into(), @@ -104,21 +118,54 @@ fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { .expect("accepted") } -fn execute( +fn execute_with_provenance( request: &AnalysisRunRequest, -) -> Result { - let documents = documents(); - let fitter = MeanFitter; + documents: &[CaseDeletionDocument], + snapshot_ids: &[String], + available_times: &[AvailableTime], + fitter: &F, +) -> Result +where + D: Clone, + F: CaseDeletionRefitter, +{ execute_case_deletion_refit_run( request, &accepted(request), - "snapshot-case-deletion-refit", + SNAPSHOT_ID, cutoff(), - &CaseDeletionRefitInput::new(&documents, "topic-model-run", &fitter), + &CaseDeletionRefitInput::new( + documents, + snapshot_ids, + available_times, + "topic-model-run", + fitter, + ), "2026-08-02T00:00:00Z", ) } +fn execute_with_documents( + request: &AnalysisRunRequest, + documents: &[CaseDeletionDocument], +) -> Result { + let snapshot_ids = visible_snapshot_ids(documents.len()); + let available_times = visible_available_times(documents.len()); + execute_with_provenance( + request, + documents, + &snapshot_ids, + &available_times, + &MeanFitter, + ) +} + +fn execute( + request: &AnalysisRunRequest, +) -> Result { + execute_with_documents(request, &documents()) +} + #[test] fn exhaustive_refits_emit_digest_bound_counts_without_reweighting() { let request = request(); @@ -169,24 +216,97 @@ fn equivalent_cutoff_spellings_bind_the_same_instant() { assert_eq!(equivalent.terminal_result.summary, baseline.terminal_result.summary); } +#[test] +fn future_duplicate_evidence_cannot_change_historical_replay() { + let request = request(); + let baseline_documents = documents(); + let baseline = execute_with_documents(&request, &baseline_documents).expect("baseline"); + + let mut replay_documents = baseline_documents; + replay_documents.push(CaseDeletionDocument { + document_id: "document-a".into(), + evidence: 10_000.0, + }); + let replay_snapshot_ids = visible_snapshot_ids(replay_documents.len()); + let mut replay_available_times = visible_available_times(replay_documents.len()); + *replay_available_times.last_mut().expect("future availability") = + available_time("2026-08-02T00:00:00Z"); + let replay = execute_with_provenance( + &request, + &replay_documents, + &replay_snapshot_ids, + &replay_available_times, + &MeanFitter, + ) + .expect("future evidence must be censored before duplicate/scientific admission"); + assert_eq!(replay.artifact, baseline.artifact); + assert_eq!(replay.terminal_result.summary, baseline.terminal_result.summary); +} + +#[test] +fn cross_snapshot_and_misaligned_provenance_fail_closed() { + let request = request(); + let documents = documents(); + let available_times = visible_available_times(documents.len()); + let mut wrong_snapshot_ids = visible_snapshot_ids(documents.len()); + wrong_snapshot_ids[1] = "snapshot-other".into(); + assert_eq!( + execute_with_provenance( + &request, + &documents, + &wrong_snapshot_ids, + &available_times, + &MeanFitter, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let short_snapshot_ids = vec![SNAPSHOT_ID.to_owned(); documents.len() - 1]; + assert_eq!( + execute_with_provenance( + &request, + &documents, + &short_snapshot_ids, + &available_times, + &MeanFitter, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn visible_duplicate_identity_still_fails_closed() { + let request = request(); + let mut duplicate_documents = documents(); + duplicate_documents.push(CaseDeletionDocument { + document_id: "document-a".into(), + evidence: 13.0, + }); + assert_eq!( + execute_with_documents(&request, &duplicate_documents), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + #[test] fn oversized_case_deletion_census_fails_before_any_fitter_call() { let request = request(); let documents = (0..257) .map(|index| CaseDeletionDocument { document_id: format!("document-{index}"), - evidence: f64::from(index), + evidence: f64::from(u32::try_from(index).expect("small test index")), }) .collect::>(); + let snapshot_ids = visible_snapshot_ids(documents.len()); + let available_times = visible_available_times(documents.len()); let fitter = CountingFitter::default(); assert_eq!( - execute_case_deletion_refit_run( + execute_with_provenance( &request, - &accepted(&request), - "snapshot-case-deletion-refit", - cutoff(), - &CaseDeletionRefitInput::new(&documents, "topic-model-run", &fitter), - "2026-08-02T00:00:00Z", + &documents, + &snapshot_ids, + &available_times, + &fitter, ), Err(AnalysisEngineError::LimitExceeded) ); @@ -200,28 +320,21 @@ fn invalid_corpus_and_fitter_refusal_fail_closed() { document_id: "document-a".into(), evidence: 1.0, }]; - let fitter = MeanFitter; assert_eq!( - execute_case_deletion_refit_run( - &request, - &accepted(&request), - "snapshot-case-deletion-refit", - cutoff(), - &CaseDeletionRefitInput::new(&one, "topic-model-run", &fitter), - "2026-08-02T00:00:00Z", - ), + execute_with_documents(&request, &one), Err(AnalysisEngineError::InvalidEvidence) ); + let documents = documents(); - let refusing = RefusingFitter; + let snapshot_ids = visible_snapshot_ids(documents.len()); + let available_times = visible_available_times(documents.len()); assert_eq!( - execute_case_deletion_refit_run( + execute_with_provenance( &request, - &accepted(&request), - "snapshot-case-deletion-refit", - cutoff(), - &CaseDeletionRefitInput::new(&documents, "topic-model-run", &refusing), - "2026-08-02T00:00:00Z", + &documents, + &snapshot_ids, + &available_times, + &RefusingFitter, ), Err(AnalysisEngineError::CaseDeletionFitFailure) ); @@ -231,14 +344,21 @@ fn invalid_corpus_and_fitter_refusal_fail_closed() { fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { let request = request(); let documents = documents(); - let fitter = MeanFitter; + let snapshot_ids = visible_snapshot_ids(documents.len()); + let available_times = visible_available_times(documents.len()); assert_eq!( execute_case_deletion_refit_run( &request, &accepted(&request), "other-snapshot", cutoff(), - &CaseDeletionRefitInput::new(&documents, "topic-model-run", &fitter), + &CaseDeletionRefitInput::new( + &documents, + &snapshot_ids, + &available_times, + "topic-model-run", + &MeanFitter, + ), "2026-08-02T00:00:00Z", ), Err(AnalysisEngineError::SnapshotMismatch) @@ -286,14 +406,21 @@ fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { fn invalid_completed_at_fails_terminal_result_construction() { let request = request(); let documents = documents(); - let fitter = MeanFitter; + let snapshot_ids = visible_snapshot_ids(documents.len()); + let available_times = visible_available_times(documents.len()); assert_eq!( execute_case_deletion_refit_run( &request, &accepted(&request), - "snapshot-case-deletion-refit", + SNAPSHOT_ID, cutoff(), - &CaseDeletionRefitInput::new(&documents, "topic-model-run", &fitter), + &CaseDeletionRefitInput::new( + &documents, + &snapshot_ids, + &available_times, + "topic-model-run", + &MeanFitter, + ), "not-a-timestamp", ), Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) From 816917b5089320595f6dcf97db88f8929225a8b0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:03:28 +0900 Subject: [PATCH 07/12] docs(adr): make case-deletion temporal and resource policy explicit --- .../0056-case-deletion-refit-analysis-run.md | 105 +++++++++--------- 1 file changed, 51 insertions(+), 54 deletions(-) diff --git a/docs/adr/0056-case-deletion-refit-analysis-run.md b/docs/adr/0056-case-deletion-refit-analysis-run.md index 8e5e10013..af2391c01 100644 --- a/docs/adr/0056-case-deletion-refit-analysis-run.md +++ b/docs/adr/0056-case-deletion-refit-analysis-run.md @@ -1,87 +1,84 @@ # ADR 0056 — Exhaustive case-deletion refit as an analysis-run output profile -**Decision status:** Accepted +**Decision status:** Proposed **Implementation maturity:** active-PR — composed on this branch; not implemented-main **Date:** 2026-08-31 +**Last repaired:** 2026-09-14 **Supersedes:** None; complements ADR 0012 (producer-owned case-deletion influence) and ADR 0022 (cutoff-safe analysis-run execution). **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 runs the same scientific fitter on the complete corpus -and on every actual `D \ {i}` corpus inside -`analysis_engine::fit_exhaustive_case_deletion`. Operators still cannot -request that runner as a digest-bound analysis-run output. Fitted -candidate-`K` selection, Pareto-front selection, composed fitted-lineage, -and topic activity remain different profiles. Full Bayesian sampling, GPU, -and topic birth/split/merge remain later GAP-004 work and are not this -slice. +Protected main already runs the same scientific fitter on the complete corpus and on every actual `D \ {i}` corpus inside `analysis_engine::fit_exhaustive_case_deletion`. That runner owns scientific refitting, but it intentionally does not own Analysis Run snapshot provenance or evidence-availability admission. -Reweighting, a fixed posterior, or a diagonal approximation must not -replace an actual deleted-data fit. +The original profile branch incorrectly described itself as cutoff-safe while passing every supplied `CaseDeletionDocument` directly into exhaustive fitting. It had no per-document snapshot or `AvailableTime`, compared request and executor cutoffs as RFC 3339 text, and placed the domain inference claim in terminal `validation_status`. It also admitted an unbounded number of exhaustive deletions even though the runner retains `n(n-1)` deleted-corpus document identities plus one full posterior and one posterior per deletion. Those are application-boundary defects, not reasons to copy the fitter. + +Reweighting, a fixed posterior, or a diagonal approximation must not replace an actual deleted-data fit. ## Decision -Add the `case_deletion_refit_v1` analysis-run output profile to -`analysis_engine`. The executor: - -- consumes already-constructed `CaseDeletionDocument` values, a seed-domain - base, and an existing `CaseDeletionRefitter`; -- requires the request snapshot and knowledge cutoff to match the offered - construction; -- invokes `fit_exhaustive_case_deletion` without reimplementing leave-one-out - fitting; -- emits a canonical SHA-256-digested `tepp.case_deletion_refit.v1` artifact - with document count, deletion-refit count, independent seed-domain count, - the full-fit seed domain, and inference status - `exhaustive_actual_deletion_not_reweighting_approx`; -- keeps raw posteriors with the scientific fitter rather than copying them - onto the operator artifact; -- refuses reuse of `composed_fitted_lineage_v1`, `fitted_candidate_k_v1`, - `pareto_candidate_k_v1`, and `trsl_topic_lineage_v1` as this profile; -- does not invent a Bayesian sampler, persist rows, select GPU backends, or - emit topic birth/split/merge. - -This is exhaustive actual deletion, not reweighting and not a posterior -sampler. +Add the `case_deletion_refit_v1` Analysis Run output profile to `analysis_engine` as a historical-admission adapter over the existing exhaustive fitter. + +The adapter: + +- keeps `CaseDeletionDocument` as the scientific fitter contract and carries immutable snapshot IDs plus `AvailableTime` in aligned application-layer slices; +- rejects provenance-length mismatch and cross-snapshot evidence before scientific fitting; +- compares parsed `KnowledgeCutoff::instant()` values, so equivalent RFC 3339 spellings of one instant bind identically; +- excludes same-snapshot evidence with `AvailableTime > KnowledgeCutoff` before duplicate/scientific admission, preserving historical replay invariance; +- keeps duplicate identities among evidence actually visible at the cutoff fail-closed through the existing runner; +- bounds raw candidate evidence by `MAX_EVIDENCE_UNITS` before adapter allocation; +- bounds exhaustive materialization by at most `256 * 255 = 65,280` retained document identities, which implies at most 256 admitted documents and 257 fitter invocations including the full fit; +- rejects an oversized admitted census before calling the fitter; +- invokes `fit_exhaustive_case_deletion` without reimplementing leave-one-out fitting; +- emits a canonical SHA-256-digested `tepp.case_deletion_refit.v1` artifact with admitted document count, deletion-refit count, independent seed-domain count, full-fit seed domain, and inference status `exhaustive_actual_deletion_not_reweighting_approx`; +- reports terminal provider validation separately as `validated`; +- keeps raw posteriors with the scientific fitter rather than copying them onto the operator artifact; +- keeps the 256 KiB `from_json` cap as an untrusted-input boundary while proving the maximal valid canonical artifact is smaller than that cap; +- refuses reuse of `composed_fitted_lineage_v1`, `fitted_candidate_k_v1`, `pareto_candidate_k_v1`, and `trsl_topic_lineage_v1` as this profile; +- does not invent a Bayesian sampler, persist rows, select GPU backends, or emit topic birth/split/merge. + +Historical replay invariant: within the raw operational input bound, adding same-snapshot evidence that becomes available only after the requested cutoff cannot change the earlier artifact or terminal result. Cross-snapshot evidence is a provenance violation and is rejected rather than censored. ## Alternatives considered -1. Bind another fitted candidate-`K` or composed-lineage profile — rejected - because those binds are already live as separate analysis-run profiles. -2. Invent a Bayesian sampler or topic birth/split/merge engine — rejected - because those functions do not exist on protected main. -3. Copy raw posteriors onto the operator artifact — rejected because the - fitter owns posterior meaning and the analysis-run contract stays - identity-free and bounded. -4. Bind the existing exhaustive runner to ADR 0022's analysis-run profile — - accepted. +1. Put snapshot and availability fields into reusable `CaseDeletionDocument` — rejected because those fields belong to Analysis Run historical admission, not the scientific fitter's reusable document contract. +2. Treat all supplied documents as already cutoff-admitted — rejected because the public profile would then make future evidence capable of changing earlier results. +3. Use RFC 3339 string equality — rejected because two legal spellings can identify the same instant. +4. Reuse the general `MAX_EVIDENCE_UNITS = 100_000` bound as the exhaustive-refit budget — rejected because the actual runner retains a quadratic `n(n-1)` identity population and fitter-owned posteriors; the exhaustive path needs its own materially smaller bound. +5. Stream or discard deleted-corpus identities to admit larger corpora — deferred. That is a scientific-runner representation change and requires its own evidence before changing this profile's resource limit. +6. Copy raw posteriors onto the operator artifact — rejected because the fitter owns posterior meaning and the Analysis Run artifact remains bounded. ## Consequences -Operators can request cutoff-safe exhaustive actual case-deletion as a -digest-bound terminal result. The artifact does not claim reweighting, -influence diagnostics, Bayesian sampling, GPU parity, or topic -birth/split/merge. Snapshot/profile/cutoff mismatch, invalid corpora, and -fitter refusal fail closed. +Operators can request historically reproducible exhaustive actual case deletion as a digest-bound terminal result without making future evidence visible to an earlier cutoff. The profile now has an explicit resource denominator tied to the runner's quadratic retained-identity representation. Larger corpora fail before any scientific fit instead of monopolizing a worker. + +The 256-document ceiling is an application-path safety contract for the current representation, not a claim that case-deletion science is intrinsically limited to 256 documents. Raising it requires changing or re-proving the retained representation and fitter/posterior resource envelope. + +This branch is still unmerged. `Proposed` remains the correct ADR status until protected-main integration and release gates are satisfied. + +## Repair evidence + +- RED `bc877e152fc9ad239de88f7050fd95854cdefab6` demonstrates that equivalent cutoff spellings, terminal validation/domain-claim separation, and an oversized 257-document census were not enforced on the predecessor source. +- Causal source repair `eff6eafcec5d644d3414332bd6ce750c344652bd` adds explicit snapshot/availability provenance, instant-based cutoff binding, historical censoring before scientific admission, the quadratic retained-identity resource budget, terminal `validated`, and bounded canonical serialization. +- Runtime dependency repair `6d0fb9081334e30dbe062aa11bc594eed801f3e7` promotes canonical `corpus_split::cutoff_eligible` from dev-only to production dependency. +- Contract migration `4a85e41519bf806334d2d4f41cde9938d9cd8d6c` proves historical replay invariance, cross-snapshot/misaligned-provenance refusal, visible-duplicate refusal, equivalent cutoff instants, and zero fitter calls for a 257-document census. ## Verification -The PR includes Rust unit and integration tests for successful exhaustive -counts, invalid corpora, fitter refusal, snapshot/profile/cutoff mismatch -including reuse of live sibling profiles, and artifact tampering. Run: +Required exact-head evidence includes at least: ```text cargo fmt --all -- --check cargo test -p analysis_engine cargo clippy -p analysis_engine --all-targets -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc -p analysis_engine --no-deps python3 scripts/validate_documentation.py +python3 scripts/check_docstrings.py ``` +Repository merge/release acceptance additionally requires current-head owned-production line and branch coverage, the organization security/CodeQL/documentation workflows, resolved valid review findings, and qualifying independent review. Predecessor receipts do not transfer after a head change. + ## Rollback and supersession -Rollback removes the `case_deletion_refit_v1` profile. No persisted schema -migration is introduced. Supersede only with an ADR that keeps actual -deleted-data fits distinct from reweighting, fixed posteriors, and -Bayesian sampling. +Rollback removes the `case_deletion_refit_v1` profile without changing the protected-main scientific fitter. No persisted schema migration is introduced. Supersede only with an ADR that preserves actual deleted-data fits, historical cutoff/provenance semantics, explicit resource admission, and the distinction from reweighting, fixed posteriors, and Bayesian sampling. From 9e6164363a3fcad496caf57c70a5572c507c87a4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:04:47 +0900 Subject: [PATCH 08/12] docs(doctoring): currentize case-deletion admission contract --- .../case-deletion-refit-analysis-run.md | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/doctoring/case-deletion-refit-analysis-run.md b/docs/doctoring/case-deletion-refit-analysis-run.md index 24ed1657c..2a80d78be 100644 --- a/docs/doctoring/case-deletion-refit-analysis-run.md +++ b/docs/doctoring/case-deletion-refit-analysis-run.md @@ -2,16 +2,27 @@ **Active slice:** ADR 0056 / `case_deletion_refit_v1` **Protected-main status:** not implemented-main +**ADR maturity:** Proposed -`analysis_engine` already fits the complete corpus and every actual -`D \ {i}` corpus through `fit_exhaustive_case_deletion`. This slice binds -that runner to a cutoff-safe analysis-run profile so an operator can -request a digest-bound terminal result. +`analysis_engine` already owns the scientific operation that fits the complete corpus and every actual `D \ {i}` corpus through `fit_exhaustive_case_deletion`. This slice does not duplicate that fitter. It supplies the Analysis Run historical-admission boundary that the reusable fitter deliberately does not own. -The executor refuses reweighting, a fixed posterior, and a diagonal -approximation as substitutes for an actual deleted-data fit. Raw posteriors -stay with the scientific fitter. It is not a Bayesian sampler, not GPU -execution, and not topic birth/split/merge. +The predecessor branch was not actually cutoff-safe. `CaseDeletionDocument` had no snapshot or availability provenance, every supplied document reached fitting, request/executor cutoffs were compared as RFC 3339 text, and terminal `validation_status` reused the domain inference label. The exhaustive runner also stores `n(n-1)` retained document identities across deletion results plus full/deletion posteriors, so a generic artifact byte cap or `MAX_EVIDENCE_UNITS` alone was not an adequate worker-resource bound. -Exact-head Checks and two independent approvals are required before any -implemented-main claim. +Current repair lineage: + +- RED `bc877e152fc9ad239de88f7050fd95854cdefab6` adds equivalent-cutoff, provider-status, and oversized-census/no-fitter-call contracts against the predecessor implementation. +- Source repair `eff6eafcec5d644d3414332bd6ce750c344652bd` adds aligned immutable snapshot IDs and `AvailableTime`, compares `KnowledgeCutoff::instant()`, rejects cross-snapshot rows, excludes same-snapshot future-unavailable rows before scientific admission, keeps visible duplicates fail-closed through the protected-main runner, and separates terminal `validated` from artifact inference. +- The same repair caps current exhaustive representation at 65,280 retained document identities (`256 * 255`), so at most 256 admitted documents and 257 full/deletion fitter invocations are allowed. An oversized admitted census is rejected before any fitter call. +- `6d0fb9081334e30dbe062aa11bc594eed801f3e7` promotes canonical `corpus_split::cutoff_eligible` to a runtime dependency. +- `4a85e41519bf806334d2d4f41cde9938d9cd8d6c` migrates the integration contract and proves future-duplicate replay invariance, cross-snapshot/misaligned-provenance refusal, visible-duplicate refusal, equivalent cutoff instants, and zero fitter calls at 257 admitted documents. +- ADR repair `816917b5089320595f6dcf97db88f8929225a8b0` records the temporal/resource decision and returns ADR 0056 from premature `Accepted` authority to `Proposed`. + +Historical replay invariant: evidence from the requested snapshot whose `AvailableTime` is after the requested cutoff cannot alter the earlier admitted corpus, artifact, or terminal result. Cross-snapshot evidence is a provenance violation and fails closed rather than being silently censored. + +The resource ceiling is representation-specific, not a scientific claim that case-deletion analysis is intrinsically limited to 256 documents. Supporting larger corpora requires a separately reviewed representation/resource change, such as eliminating quadratic retained-identity materialization, while preserving actual deleted-data refits and fitter-owned posterior semantics. + +Raw posteriors remain with the scientific fitter. Reweighting, a fixed posterior, or a diagonal approximation is not an acceptable substitute for an actual `D \ {i}` fit. This profile is not a Bayesian sampler, GPU execution, or topic birth/split/merge. + +The large shared `docs/adr/README.md`, `docs/TRACEABILITY.md`, and product-gap baseline remain consolidation surfaces. ADR 0056 must not be advertised as `Accepted` there before protected merge; shared current-state edits belong to the canonical documentation/consolidation lane rather than being treated as complete by this doctoring note. + +Merge authority is the unchanged surviving exact head after conflict-resolving consolidation. Live organization ruleset 18156473 requires one qualifying current-head approval, dismisses stale approvals after pushes, requires all review threads resolved, and enforces organization required workflows. Thread resolution or predecessor checks are not approval/merge evidence. Exact-head owned-production line/branch coverage, Rust/documentation/security/CodeQL gates, and the qualifying independent approval must be reacquired after every head or base change. From 7705111516eda65b38e4cf7d8d3069f786bab65d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:09:30 +0900 Subject: [PATCH 09/12] fix(analysis): bound case-deletion retained identity bytes --- .../src/case_deletion_refit_artifact.rs | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/analysis_engine/src/case_deletion_refit_artifact.rs b/crates/analysis_engine/src/case_deletion_refit_artifact.rs index 2a848d076..2798622d9 100644 --- a/crates/analysis_engine/src/case_deletion_refit_artifact.rs +++ b/crates/analysis_engine/src/case_deletion_refit_artifact.rs @@ -22,7 +22,9 @@ pub const CASE_DELETION_REFIT_MODEL_CONTRACT_VERSION: &str = "case_deletion_refi pub const CASE_DELETION_REFIT_OUTPUT_PROFILE: &str = "case_deletion_refit_v1"; /// Maximum accepted case-deletion artifact JSON size. pub const CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; -const MAX_CASE_DELETION_RETAINED_IDENTITIES: usize = 256 * 255; +const MAX_CASE_DELETION_DOCUMENTS: usize = 256; +const MAX_CASE_DELETION_RETAINED_IDENTITIES: usize = + MAX_CASE_DELETION_DOCUMENTS * (MAX_CASE_DELETION_DOCUMENTS - 1); const CASE_DELETION_REFIT_INFERENCE_STATUS: &str = "exhaustive_actual_deletion_not_reweighting_approx"; @@ -196,9 +198,11 @@ pub struct CaseDeletionRefitExecution { /// reimplement leave-one-out fitting, reweighting, or a diagonal /// approximation. Evidence availability is admitted before duplicate/scientific /// fitting, so evidence not yet available at the cutoff cannot alter a -/// historical replay. Raw posteriors stay with the scientific fitter; the -/// operator artifact carries only bounded counts and seed-domain identity. -/// This is not a Bayesian sampler and not GPU execution. +/// historical replay. Visible document identities are bounded before they can +/// enter the runner's quadratic retained-identity representation. Raw +/// posteriors stay with the scientific fitter; the operator artifact carries +/// only bounded counts and seed-domain identity. This is not a Bayesian sampler +/// and not GPU execution. /// /// # Errors /// @@ -230,7 +234,7 @@ where || request.output_profile != CASE_DELETION_REFIT_OUTPUT_PROFILE || input.documents().len() != input.snapshot_ids().len() || input.documents().len() != input.available_times().len() - || !valid_identifier(input.seed_domain_base()) + || !valid_seed_domain_base(input.seed_domain_base()) { return Err(AnalysisEngineError::InvalidEvidence); } @@ -238,7 +242,8 @@ where return Err(AnalysisEngineError::LimitExceeded); } - let mut admitted_documents = Vec::with_capacity(input.documents().len().min(256)); + let mut admitted_documents = + Vec::with_capacity(input.documents().len().min(MAX_CASE_DELETION_DOCUMENTS)); for ((document, document_snapshot_id), available_time) in input .documents() .iter() @@ -251,6 +256,9 @@ where if !cutoff_eligible(available_time, &knowledge_cutoff) { continue; } + if !valid_identifier(&document.document_id) { + return Err(AnalysisEngineError::InvalidEvidence); + } let next_document_count = admitted_documents .len() .checked_add(1) @@ -305,6 +313,10 @@ where }) } +fn valid_seed_domain_base(seed_domain_base: &str) -> bool { + valid_identifier(seed_domain_base) && valid_identifier(&format!("{seed_domain_base}:full")) +} + fn within_case_deletion_resource_budget(document_count: usize) -> bool { document_count .checked_mul(document_count.saturating_sub(1)) @@ -318,7 +330,7 @@ mod tests { use super::{ CASE_DELETION_REFIT_ARTIFACT_BYTE_LIMIT, CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION, CASE_DELETION_REFIT_INFERENCE_STATUS, CaseDeletionRefitArtifact, CaseDeletionRefitInput, - within_case_deletion_resource_budget, + valid_seed_domain_base, within_case_deletion_resource_budget, }; use crate::{AnalysisEngineError, CaseDeletionDocument}; use temporal_core::AvailableTime; @@ -453,6 +465,12 @@ mod tests { assert!(!within_case_deletion_resource_budget(257)); } + #[test] + fn seed_domain_base_is_valid_only_when_derived_full_domain_is_bounded() { + assert!(valid_seed_domain_base("topic-model-run")); + assert!(!valid_seed_domain_base(&"s".repeat(256))); + } + #[test] fn input_accessors_expose_documents_and_provenance() { let documents = [CaseDeletionDocument { From 8f2aca3ef9afd5d9ed573e9b15197ccb3b75f50f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:10:35 +0900 Subject: [PATCH 10/12] docs(adr): bound case-deletion retained identity bytes --- docs/adr/0056-case-deletion-refit-analysis-run.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/adr/0056-case-deletion-refit-analysis-run.md b/docs/adr/0056-case-deletion-refit-analysis-run.md index af2391c01..fa56ff519 100644 --- a/docs/adr/0056-case-deletion-refit-analysis-run.md +++ b/docs/adr/0056-case-deletion-refit-analysis-run.md @@ -26,9 +26,11 @@ The adapter: - rejects provenance-length mismatch and cross-snapshot evidence before scientific fitting; - compares parsed `KnowledgeCutoff::instant()` values, so equivalent RFC 3339 spellings of one instant bind identically; - excludes same-snapshot evidence with `AvailableTime > KnowledgeCutoff` before duplicate/scientific admission, preserving historical replay invariance; +- bounds each cutoff-visible document identity with the Analysis Run identifier contract before it can be cloned into the exhaustive runner; - keeps duplicate identities among evidence actually visible at the cutoff fail-closed through the existing runner; - bounds raw candidate evidence by `MAX_EVIDENCE_UNITS` before adapter allocation; - bounds exhaustive materialization by at most `256 * 255 = 65,280` retained document identities, which implies at most 256 admitted documents and 257 fitter invocations including the full fit; +- prevalidates the derived `:full` seed-domain identity so an oversized seed base cannot trigger all refits and only then fail artifact validation; - rejects an oversized admitted census before calling the fitter; - invokes `fit_exhaustive_case_deletion` without reimplementing leave-one-out fitting; - emits a canonical SHA-256-digested `tepp.case_deletion_refit.v1` artifact with admitted document count, deletion-refit count, independent seed-domain count, full-fit seed domain, and inference status `exhaustive_actual_deletion_not_reweighting_approx`; @@ -51,9 +53,9 @@ Historical replay invariant: within the raw operational input bound, adding same ## Consequences -Operators can request historically reproducible exhaustive actual case deletion as a digest-bound terminal result without making future evidence visible to an earlier cutoff. The profile now has an explicit resource denominator tied to the runner's quadratic retained-identity representation. Larger corpora fail before any scientific fit instead of monopolizing a worker. +Operators can request historically reproducible exhaustive actual case deletion as a digest-bound terminal result without making future evidence visible to an earlier cutoff. The profile now has an explicit resource denominator tied to the runner's quadratic retained-identity representation. Visible document identities are bounded before entering that representation, so the retained-identity component has both a count and per-identity byte ceiling. Larger admitted corpora fail before any scientific fit instead of monopolizing a worker. -The 256-document ceiling is an application-path safety contract for the current representation, not a claim that case-deletion science is intrinsically limited to 256 documents. Raising it requires changing or re-proving the retained representation and fitter/posterior resource envelope. +The 256-document ceiling is an application-path safety contract for the current representation, not a claim that case-deletion science is intrinsically limited to 256 documents. It caps the number of fitter-owned posteriors retained by the current runner, but it does not assert a generic byte size for arbitrary fitter-owned evidence or posterior type `P`; those remain scientific-fitter owner contracts. Raising the ceiling or claiming a complete worker-memory envelope requires changing or re-proving the retained representation and concrete fitter/posterior resource contract. This branch is still unmerged. `Proposed` remains the correct ADR status until protected-main integration and release gates are satisfied. @@ -63,6 +65,7 @@ This branch is still unmerged. `Proposed` remains the correct ADR status until p - Causal source repair `eff6eafcec5d644d3414332bd6ce750c344652bd` adds explicit snapshot/availability provenance, instant-based cutoff binding, historical censoring before scientific admission, the quadratic retained-identity resource budget, terminal `validated`, and bounded canonical serialization. - Runtime dependency repair `6d0fb9081334e30dbe062aa11bc594eed801f3e7` promotes canonical `corpus_split::cutoff_eligible` from dev-only to production dependency. - Contract migration `4a85e41519bf806334d2d4f41cde9938d9cd8d6c` proves historical replay invariance, cross-snapshot/misaligned-provenance refusal, visible-duplicate refusal, equivalent cutoff instants, and zero fitter calls for a 257-document census. +- Resource-hardening repair `7705111516eda65b38e4cf7d8d3069f786bab65d` additionally bounds every visible retained identity and prevalidates the derived full-fit seed domain before expensive fitting. ## Verification From 738a97d76f9766afbc099cec97820e9cb5ee31da Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:11:23 +0900 Subject: [PATCH 11/12] docs(doctoring): record residual fitter resource envelope --- docs/doctoring/case-deletion-refit-analysis-run.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/doctoring/case-deletion-refit-analysis-run.md b/docs/doctoring/case-deletion-refit-analysis-run.md index 2a80d78be..32c4a38c4 100644 --- a/docs/doctoring/case-deletion-refit-analysis-run.md +++ b/docs/doctoring/case-deletion-refit-analysis-run.md @@ -15,11 +15,12 @@ Current repair lineage: - The same repair caps current exhaustive representation at 65,280 retained document identities (`256 * 255`), so at most 256 admitted documents and 257 full/deletion fitter invocations are allowed. An oversized admitted census is rejected before any fitter call. - `6d0fb9081334e30dbe062aa11bc594eed801f3e7` promotes canonical `corpus_split::cutoff_eligible` to a runtime dependency. - `4a85e41519bf806334d2d4f41cde9938d9cd8d6c` migrates the integration contract and proves future-duplicate replay invariance, cross-snapshot/misaligned-provenance refusal, visible-duplicate refusal, equivalent cutoff instants, and zero fitter calls at 257 admitted documents. -- ADR repair `816917b5089320595f6dcf97db88f8929225a8b0` records the temporal/resource decision and returns ADR 0056 from premature `Accepted` authority to `Proposed`. +- `7705111516eda65b38e4cf7d8d3069f786bab65d` bounds every cutoff-visible document identity before the runner's quadratic identity cloning and prevalidates the derived full-fit seed domain so malformed/oversized identity state cannot consume the full refit budget before failing artifact construction. +- ADR repairs `816917b5089320595f6dcf97db88f8929225a8b0` and `8f2aca3ef9afd5d9ed573e9b15197ccb3b75f50f` record the temporal/resource decision and return ADR 0056 from premature `Accepted` authority to `Proposed`. Historical replay invariant: evidence from the requested snapshot whose `AvailableTime` is after the requested cutoff cannot alter the earlier admitted corpus, artifact, or terminal result. Cross-snapshot evidence is a provenance violation and fails closed rather than being silently censored. -The resource ceiling is representation-specific, not a scientific claim that case-deletion analysis is intrinsically limited to 256 documents. Supporting larger corpora requires a separately reviewed representation/resource change, such as eliminating quadratic retained-identity materialization, while preserving actual deleted-data refits and fitter-owned posterior semantics. +The resource ceiling is representation-specific, not a scientific claim that case-deletion analysis is intrinsically limited to 256 documents. The profile now bounds retained identity count and each visible identity's bytes and caps the number of fitter-owned posteriors. It still cannot make a truthful generic byte-size claim for arbitrary scientific evidence `D` or posterior `P`. Issue #499 owns that remaining production fitter/posterior resource-envelope gap; a larger ceiling or complete worker-memory/SLO claim requires a concrete fitter-owned resource contract or a runner representation change with exact measurement. Raw posteriors remain with the scientific fitter. Reweighting, a fixed posterior, or a diagonal approximation is not an acceptable substitute for an actual `D \ {i}` fit. This profile is not a Bayesian sampler, GPU execution, or topic birth/split/merge. From b38fdce0c2dc1eb244ad2abf0fc4bed42dfe656c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 12:13:19 +0900 Subject: [PATCH 12/12] test(analysis): cover case-deletion admission boundaries --- .../case_deletion_refit_execution_contract.rs | 104 +++++++++++++++++- 1 file changed, 101 insertions(+), 3 deletions(-) diff --git a/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs b/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs index 377ff6914..d3e2ed423 100644 --- a/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs +++ b/crates/analysis_engine/tests/case_deletion_refit_execution_contract.rs @@ -6,7 +6,7 @@ use analysis_engine::{ AnalysisEngineError, CASE_DELETION_REFIT_ARTIFACT_SCHEMA_VERSION, CASE_DELETION_REFIT_MODEL_CONTRACT_VERSION, CASE_DELETION_REFIT_OUTPUT_PROFILE, CaseDeletionDocument, CaseDeletionFitContext, CaseDeletionRefitInput, CaseDeletionRefitter, - execute_case_deletion_refit_run, + MAX_EVIDENCE_UNITS, execute_case_deletion_refit_run, }; use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState, ApiError}; @@ -118,11 +118,12 @@ fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { .expect("accepted") } -fn execute_with_provenance( +fn execute_with_seed_domain( request: &AnalysisRunRequest, documents: &[CaseDeletionDocument], snapshot_ids: &[String], available_times: &[AvailableTime], + seed_domain_base: &str, fitter: &F, ) -> Result where @@ -138,13 +139,34 @@ where documents, snapshot_ids, available_times, - "topic-model-run", + seed_domain_base, fitter, ), "2026-08-02T00:00:00Z", ) } +fn execute_with_provenance( + request: &AnalysisRunRequest, + documents: &[CaseDeletionDocument], + snapshot_ids: &[String], + available_times: &[AvailableTime], + fitter: &F, +) -> Result +where + D: Clone, + F: CaseDeletionRefitter, +{ + execute_with_seed_domain( + request, + documents, + snapshot_ids, + available_times, + "topic-model-run", + fitter, + ) +} + fn execute_with_documents( request: &AnalysisRunRequest, documents: &[CaseDeletionDocument], @@ -272,6 +294,19 @@ fn cross_snapshot_and_misaligned_provenance_fail_closed() { ), Err(AnalysisEngineError::InvalidEvidence) ); + + let snapshot_ids = visible_snapshot_ids(documents.len()); + let short_available_times = visible_available_times(documents.len() - 1); + assert_eq!( + execute_with_provenance( + &request, + &documents, + &snapshot_ids, + &short_available_times, + &MeanFitter, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); } #[test] @@ -288,6 +323,44 @@ fn visible_duplicate_identity_still_fails_closed() { ); } +#[test] +fn oversized_visible_identity_and_derived_seed_fail_before_fitter() { + let request = request(); + let mut invalid_documents = documents(); + invalid_documents[0].document_id = "d".repeat(257); + let snapshot_ids = visible_snapshot_ids(invalid_documents.len()); + let available_times = visible_available_times(invalid_documents.len()); + let fitter = CountingFitter::default(); + assert_eq!( + execute_with_provenance( + &request, + &invalid_documents, + &snapshot_ids, + &available_times, + &fitter, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!(fitter.calls.load(Ordering::SeqCst), 0); + + let documents = documents(); + let snapshot_ids = visible_snapshot_ids(documents.len()); + let available_times = visible_available_times(documents.len()); + let fitter = CountingFitter::default(); + assert_eq!( + execute_with_seed_domain( + &request, + &documents, + &snapshot_ids, + &available_times, + &"s".repeat(256), + &fitter, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!(fitter.calls.load(Ordering::SeqCst), 0); +} + #[test] fn oversized_case_deletion_census_fails_before_any_fitter_call() { let request = request(); @@ -313,6 +386,31 @@ fn oversized_case_deletion_census_fails_before_any_fitter_call() { assert_eq!(fitter.calls.load(Ordering::SeqCst), 0); } +#[test] +fn raw_population_limit_fails_before_any_fitter_call() { + let request = request(); + let documents = (0..=MAX_EVIDENCE_UNITS) + .map(|_| CaseDeletionDocument { + document_id: "document".into(), + evidence: 1.0, + }) + .collect::>(); + let snapshot_ids = visible_snapshot_ids(documents.len()); + let available_times = visible_available_times(documents.len()); + let fitter = CountingFitter::default(); + assert_eq!( + execute_with_provenance( + &request, + &documents, + &snapshot_ids, + &available_times, + &fitter, + ), + Err(AnalysisEngineError::LimitExceeded) + ); + assert_eq!(fitter.calls.load(Ordering::SeqCst), 0); +} + #[test] fn invalid_corpus_and_fitter_refusal_fail_closed() { let request = request();