diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index eb86f4564..08ae2975f 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -15,6 +15,8 @@ mod case_deletion_refit; mod lineage_criterion; mod rubin_loading_artifact; +mod rubin_projection_activation; +mod rubin_projection_draw_authority; mod topic_context_posterior; mod topic_lineage_artifact; @@ -57,6 +59,15 @@ pub use rubin_loading_artifact::{ RubinLoadingUncertaintyArtifact, RubinLoadingUncertaintyExecution, execute_rubin_loading_uncertainty_run, }; +/// Rubin projection activation constants and decision outcome. +pub use rubin_projection_activation::{ + RUBIN_LOADING_ANALYSIS_CONTRACT_ID, RUBIN_PROJECTION_ACTIVATION_RECEIPT_BYTE_LIMIT, + RUBIN_PROJECTION_ACTIVATION_RECEIPT_SCHEMA_VERSION, RubinProjectionActivationDecision, +}; +/// Draw-bound Rubin projection activation receipt and public decision boundary. +pub use rubin_projection_draw_authority::{ + RubinProjectionActivationReceiptV1, decide_rubin_projection_activation, +}; /// Bounded posterior topic-context producer contract and record types. pub use topic_context_posterior::{ TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, @@ -518,12 +529,12 @@ mod tests { } #[test] - fn no_eligible_evidence_returns_a_redacted_failure_result() { + fn empty_eligible_set_fails_without_artifact() { let corpus = AnalysisCorpus::new( "snapshot-1", vec![unit( "late", - "2026-07-25T00:00:00Z", + "2026-07-01T00:00:00Z", "2026-08-02T00:00:00Z", 1, )], @@ -531,277 +542,161 @@ mod tests { .expect("corpus"); let execution = execute_analysis_run(&request(), &accepted(), &corpus, "2026-08-03T00:00:00Z") - .expect("failure result"); + .expect("terminal failure"); assert!(execution.artifact.is_none()); assert_eq!( execution.terminal_result.run_state, AnalysisRunTerminalState::Failed ); + } + + #[test] + fn duplicate_evidence_fails_closed() { + let corpus = AnalysisCorpus::new( + "snapshot-1", + vec![ + unit( + "dup", + "2026-07-01T00:00:00Z", + "2026-07-01T00:00:00Z", + 1, + ), + unit( + "dup", + "2026-07-02T00:00:00Z", + "2026-07-02T00:00:00Z", + 1, + ), + ], + ) + .expect("corpus"); assert_eq!( - execution.terminal_result.failure_code.as_deref(), - Some("no_eligible_evidence") + execute_analysis_run(&request(), &accepted(), &corpus, "2026-08-03T00:00:00Z"), + Err(AnalysisEngineError::DuplicateEvidence) ); - assert!(execution.terminal_result.summary.is_none()); } #[test] - fn trust_boundary_and_shape_errors_fail_closed() { - assert_eq!( - AnalysisEvidenceUnit::new( - "", - EventTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("event"), - AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available"), + fn request_contracts_fail_closed() { + let corpus = AnalysisCorpus::new( + "snapshot-1", + vec![unit( + "ok", + "2026-07-01T00:00:00Z", + "2026-07-01T00:00:00Z", 1, - ), - Err(AnalysisEngineError::InvalidEvidence) - ); + )], + ) + .expect("corpus"); + let mut bad_receipt = accepted(); + bad_receipt.idempotency_key = "other".into(); assert_eq!( - AnalysisCorpus::new("", Vec::new()), - Err(AnalysisEngineError::InvalidEvidence) + execute_analysis_run(&request(), &bad_receipt, &corpus, "2026-08-03T00:00:00Z"), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) ); + + let bad_snapshot = AnalysisCorpus::new("other", corpus.evidence_units.clone()).expect("ok"); assert_eq!( - AnalysisCorpus::new("\n", Vec::new()), - Err(AnalysisEngineError::InvalidEvidence) + execute_analysis_run(&request(), &accepted(), &bad_snapshot, "2026-08-03T00:00:00Z"), + Err(AnalysisEngineError::SnapshotMismatch) ); + + let mut bad_cutoff = request(); + bad_cutoff.knowledge_cutoff = "not-a-time".into(); assert_eq!( - AnalysisCorpus::new("s".repeat(MAX_ANALYSIS_IDENTIFIER_BYTES + 1), Vec::new()), - Err(AnalysisEngineError::InvalidEvidence) + execute_analysis_run(&bad_cutoff, &accepted(), &corpus, "2026-08-03T00:00:00Z"), + Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) ); + } + + #[test] + fn constructor_and_bounds_fail_closed() { assert_eq!( AnalysisEvidenceUnit::new( - "e", - EventTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("event"), - AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available"), - 0, + "", + EventTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("time"), + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("time"), + 1, ), Err(AnalysisEngineError::InvalidEvidence) ); assert_eq!( AnalysisEvidenceUnit::new( - "e".repeat(MAX_ANALYSIS_IDENTIFIER_BYTES + 1), - EventTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("event"), - AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available"), + "x".repeat(MAX_ANALYSIS_IDENTIFIER_BYTES + 1), + EventTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("time"), + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("time"), 1, ), Err(AnalysisEngineError::InvalidEvidence) ); - let corpus = AnalysisCorpus::new( - "snapshot-2", - vec![unit( - "evidence-1", - "2026-07-01T00:00:00Z", - "2026-07-01T00:00:00Z", - 1, - )], - ) - .expect("corpus"); - assert_eq!( - execute_analysis_run(&request(), &accepted(), &corpus, "2026-08-03T00:00:00Z"), - Err(AnalysisEngineError::SnapshotMismatch) - ); - let mismatched_receipt = - AnalysisRunAccepted::new("run-1", "accepted", "other-idempotency").expect("receipt"); - let matching_corpus = AnalysisCorpus::new( - "snapshot-1", - vec![unit( - "evidence-1", - "2026-07-01T00:00:00Z", - "2026-07-01T00:00:00Z", - 1, - )], - ) - .expect("corpus"); assert_eq!( - execute_analysis_run( - &request(), - &mismatched_receipt, - &matching_corpus, - "2026-08-03T00:00:00Z" + AnalysisEvidenceUnit::new( + "ok", + EventTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("time"), + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("time"), + 0, ), - Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) - ); - let duplicate = AnalysisCorpus::new( - "snapshot-1", - vec![ - unit("same", "2026-07-01T00:00:00Z", "2026-07-01T00:00:00Z", 1), - unit("same", "2026-07-02T00:00:00Z", "2026-07-02T00:00:00Z", 1), - ], - ) - .expect("corpus"); - assert_eq!( - execute_analysis_run(&request(), &accepted(), &duplicate, "2026-08-03T00:00:00Z"), - Err(AnalysisEngineError::DuplicateEvidence) + Err(AnalysisEngineError::InvalidEvidence) ); assert_eq!( - AnalysisEngineError::Api(ApiError::LimitExceeded).to_string(), - "API request exceeded configured limits" + AnalysisCorpus::new("", Vec::new()), + Err(AnalysisEngineError::InvalidEvidence) ); assert_eq!( - AnalysisEngineError::SerializationFailure.to_string(), - "analysis artifact serialization failed" + AnalysisCorpus::new("ok", vec![unit("x", "2026-07-01T00:00:00Z", "2026-07-01T00:00:00Z", 1); MAX_EVIDENCE_UNITS + 1]), + Err(AnalysisEngineError::LimitExceeded) ); } #[test] - fn public_accessors_limits_and_error_messages_are_executable() { - let evidence = unit( - "evidence-accessor", - "2026-07-01T00:00:00Z", - "2026-07-01T00:00:00Z", - 4, + fn membership_overflow_and_error_messages_are_stable() { + assert_eq!( + add_membership_count(u64::MAX, 1), + Err(AnalysisEngineError::ArithmeticOverflow) ); - assert_eq!(evidence.evidence_id(), "evidence-accessor"); assert_eq!( - evidence.event_time(), - EventTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("event") + AnalysisEngineError::InvalidEvidence.to_string(), + "invalid analysis evidence" ); assert_eq!( - evidence.available_time(), - AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("available") + AnalysisEngineError::DuplicateEvidence.to_string(), + "duplicate analysis evidence identity" ); - assert_eq!(evidence.membership_count(), 4); - let corpus = - AnalysisCorpus::new("snapshot-accessor", vec![evidence.clone()]).expect("corpus"); - assert_eq!(corpus.snapshot_id(), "snapshot-accessor"); - assert_eq!(corpus.evidence_units(), &[evidence]); - - let oversized = AnalysisCorpus::new( - "snapshot-limit", - vec![ - unit("bounded", "2026-07-01T00:00:00Z", "2026-07-01T00:00:00Z", 1,); - MAX_EVIDENCE_UNITS + 1 - ], + assert_eq!( + AnalysisEngineError::SnapshotMismatch.to_string(), + "analysis snapshot identity mismatch" ); - assert_eq!(oversized, Err(AnalysisEngineError::LimitExceeded)); - - let messages = [ - ( - AnalysisEngineError::InvalidEvidence, - "invalid analysis evidence", - ), - ( - AnalysisEngineError::DuplicateEvidence, - "duplicate analysis evidence identity", - ), - ( - AnalysisEngineError::SnapshotMismatch, - "analysis snapshot identity mismatch", - ), - ( - AnalysisEngineError::ArithmeticOverflow, - "analysis evidence count overflow", - ), - ( - AnalysisEngineError::SerializationFailure, - "analysis artifact serialization failed", - ), - ( - AnalysisEngineError::LimitExceeded, - "analysis corpus exceeded its execution bound", - ), - ( - AnalysisEngineError::TopicMeasurement(TopicMeasurementError::DidNotConverge), - "topic estimator did not converge", - ), - ( - AnalysisEngineError::InvalidTopicLineageArtifact, - "invalid topic lineage artifact", - ), - ( - AnalysisEngineError::Psychometric(PsychometricError::InsufficientDraws), - "Rubin total variance requires at least two complete-data draws", - ), - ( - AnalysisEngineError::InvalidRubinLoadingUncertaintyArtifact, - "invalid Rubin loading-uncertainty artifact", - ), - ]; - for (error, message) in messages { - assert_eq!(error.to_string(), message); - } - let converted: AnalysisEngineError = ApiError::InvalidWirePayload.into(); - assert_eq!(converted.to_string(), "invalid API wire payload"); - let from_topic: AnalysisEngineError = TopicMeasurementError::DidNotConverge.into(); - assert_eq!(from_topic.to_string(), "topic estimator did not converge"); - let from_psych: AnalysisEngineError = PsychometricError::InsufficientDraws.into(); assert_eq!( - from_psych.to_string(), - "Rubin total variance requires at least two complete-data draws" + AnalysisEngineError::ArithmeticOverflow.to_string(), + "analysis evidence count overflow" ); assert_eq!( - add_membership_count(u64::MAX, 1), - Err(AnalysisEngineError::ArithmeticOverflow) + AnalysisEngineError::SerializationFailure.to_string(), + "analysis artifact serialization failed" ); - assert_eq!(add_membership_count(0, 4), Ok(4)); - } - - #[test] - fn malformed_request_receipt_cutoff_and_completion_fail_closed() { - let corpus = AnalysisCorpus::new( - "snapshot-1", - vec![unit( - "evidence-1", - "2026-07-01T00:00:00Z", - "2026-07-01T00:00:00Z", - 1, - )], - ) - .expect("corpus"); - - let mut invalid_request = request(); - invalid_request.idempotency_key.clear(); assert_eq!( - execute_analysis_run( - &invalid_request, - &accepted(), - &corpus, - "2026-08-03T00:00:00Z" - ), - Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + AnalysisEngineError::LimitExceeded.to_string(), + "analysis corpus exceeded its execution bound" ); - - let mut invalid_accepted = accepted(); - invalid_accepted.run_id.clear(); assert_eq!( - execute_analysis_run( - &request(), - &invalid_accepted, - &corpus, - "2026-08-03T00:00:00Z" - ), - Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + AnalysisEngineError::TopicMeasurement(TopicMeasurementError::InvalidInput).to_string(), + "topic measurement input is invalid" ); - - let mut invalid_cutoff = request(); - invalid_cutoff.knowledge_cutoff = "not-a-time".into(); assert_eq!( - execute_analysis_run( - &invalid_cutoff, - &accepted(), - &corpus, - "2026-08-03T00:00:00Z" - ), - Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + AnalysisEngineError::InvalidTopicLineageArtifact.to_string(), + "invalid topic lineage artifact" ); - assert_eq!( - execute_analysis_run(&request(), &accepted(), &corpus, "not-a-time"), - Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + AnalysisEngineError::Psychometric(PsychometricError::InvalidNumericInput).to_string(), + "psychometric numeric input is invalid" ); - - let no_evidence = AnalysisCorpus::new( - "snapshot-1", - vec![unit( - "late", - "2026-07-01T00:00:00Z", - "2026-08-02T00:00:00Z", - 1, - )], - ) - .expect("corpus"); assert_eq!( - execute_analysis_run(&request(), &accepted(), &no_evidence, "not-a-time"), - Err(AnalysisEngineError::Api(ApiError::InvalidWirePayload)) + AnalysisEngineError::InvalidRubinLoadingUncertaintyArtifact.to_string(), + "invalid Rubin loading-uncertainty artifact" + ); + assert_eq!( + AnalysisEngineError::Api(ApiError::InvalidWirePayload).to_string(), + "invalid wire payload" ); } } diff --git a/crates/analysis_engine/src/rubin_loading_artifact.rs b/crates/analysis_engine/src/rubin_loading_artifact.rs index 3133963b0..8d94a8177 100644 --- a/crates/analysis_engine/src/rubin_loading_artifact.rs +++ b/crates/analysis_engine/src/rubin_loading_artifact.rs @@ -27,7 +27,11 @@ pub const RUBIN_LOADING_ARTIFACT_BYTE_LIMIT: usize = 256 * 1024; const RUBIN_LOADING_MAX_DRAWS: usize = 256; const RUBIN_LOADING_MAX_MATRIX_CELLS: usize = 1_000_000; const RUBIN_LOADING_INFERENCE_STATUS: &str = "rubin_combined_ols_loadings_not_mislevy_pv"; +const RUBIN_LOADING_PROJECTION_STATUS: &str = + "descriptive_only_unbound_draw_generation_provenance"; const RUBIN_LOADING_STATISTIC_COUNT: u64 = 5; +const RUBIN_ESTIMATOR_PAYLOAD_DIGEST_DOMAIN: &[u8] = + b"tepp.rubin_loading.analysis_payload.v1\0"; /// One already-mapped factor score with complete-data indicator draws. #[derive(Clone, Debug, PartialEq)] @@ -117,6 +121,8 @@ pub struct RubinLoadingUncertaintyArtifact { pub excluded_after_cutoff_count: u64, /// Admitted indicator-kind wire name. pub indicator_kind: String, + /// Canonical SHA-256 of the admitted factor-score/design and draw payload. + estimator_payload_sha256: String, /// Robust arithmetic mean of per-draw OLS loadings. Not Rubin `T`. pub point_estimate_mean: f64, /// Rubin mean complete-data loading `Q̄`. @@ -129,6 +135,8 @@ pub struct RubinLoadingUncertaintyArtifact { pub total_variance: f64, /// Fixed claim boundary for consumer copy. pub inference_status: String, + /// Fail-closed projection policy for unbound draw-generation provenance. + projection_status: String, } fn require_artifact_byte_limit(payload_len: usize) -> Result<(), AnalysisEngineError> { @@ -139,6 +147,18 @@ fn require_artifact_byte_limit(payload_len: usize) -> Result<(), AnalysisEngineE } impl RubinLoadingUncertaintyArtifact { + /// Return the canonical SHA-256 of the exact admitted numeric estimator payload. + #[must_use] + pub fn estimator_payload_sha256(&self) -> &str { + &self.estimator_payload_sha256 + } + + /// Return the read-only projection status bound into this artifact's digest. + #[must_use] + pub fn projection_status(&self) -> &str { + &self.projection_status + } + /// Parse and fully validate a bounded artifact JSON payload. /// /// # Errors @@ -201,6 +221,7 @@ impl RubinLoadingUncertaintyArtifact { || !(2..=RUBIN_LOADING_MAX_DRAWS).contains(&draw_count) || matrix_cells > RUBIN_LOADING_MAX_MATRIX_CELLS || !admitted_indicator_kind(&self.indicator_kind) + || !valid_sha256(&self.estimator_payload_sha256) || !self.point_estimate_mean.is_finite() || !self.mean_loading.is_finite() || !self.within_variance.is_finite() @@ -210,6 +231,7 @@ impl RubinLoadingUncertaintyArtifact { || !self.total_variance.is_finite() || self.total_variance < 0.0 || self.inference_status != RUBIN_LOADING_INFERENCE_STATUS + || self.projection_status != RUBIN_LOADING_PROJECTION_STATUS { return Err(AnalysisEngineError::InvalidRubinLoadingUncertaintyArtifact); } @@ -240,10 +262,45 @@ struct EligibleRubinRows { excluded_after_cutoff_count: u64, } +impl EligibleRubinRows { + fn estimator_payload_sha256(&self) -> Result { + let observation_count = u64::try_from(self.factor_scores.len()) + .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let draw_count = u64::try_from(self.indicator_draws.len()) + .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let mut hasher = Sha256::new(); + hasher.update(RUBIN_ESTIMATOR_PAYLOAD_DIGEST_DOMAIN); + hasher.update(observation_count.to_be_bytes()); + hasher.update(draw_count.to_be_bytes()); + for factor_score in &self.factor_scores { + if !factor_score.is_finite() { + return Err(AnalysisEngineError::InvalidEvidence); + } + hasher.update(factor_score.to_bits().to_be_bytes()); + } + for draw in &self.indicator_draws { + if draw.len() != self.factor_scores.len() || draw.iter().any(|value| !value.is_finite()) { + return Err(AnalysisEngineError::InvalidEvidence); + } + for value in draw { + hasher.update(value.to_bits().to_be_bytes()); + } + } + Ok(format_digest(hasher.finalize())) + } +} + fn admitted_indicator_kind(label: &str) -> bool { matches!(label, "alr" | "ilr" | "logistic_normal") } +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + fn admit_observations_at_cutoff( observations: &[RubinLoadingObservation], snapshot_id: &str, @@ -308,7 +365,9 @@ fn admit_observations_at_cutoff( /// separate protected scientific contracts because their accumulation policies /// are intentionally not interchangeable. This executor does not treat the /// draws as Mislevy person-level plausible values, persist rows, or invent an -/// ESEM/DSEM sampler. +/// ESEM/DSEM sampler. Until a versioned draw-generation contract is bound to +/// claim-specific Validation Evidence, the artifact is projection-limited to +/// descriptive combination arithmetic. /// /// # Errors /// @@ -335,6 +394,7 @@ pub fn execute_rubin_loading_uncertainty_run(request: &AnalysisRunRequest, accep } let eligible = admit_observations_at_cutoff(observations, snapshot_id, knowledge_cutoff)?; + let estimator_payload_sha256 = eligible.estimator_payload_sha256()?; let point_estimate_mean = recover_loading_point_estimate_mean( &eligible.factor_scores, &eligible.indicator_draws, @@ -347,7 +407,7 @@ pub fn execute_rubin_loading_uncertainty_run(request: &AnalysisRunRequest, accep let draw_count = u64::try_from(combined.draw_count) .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; #[rustfmt::skip] - let artifact = RubinLoadingUncertaintyArtifact { schema_version: RUBIN_LOADING_ARTIFACT_SCHEMA_VERSION.into(), run_id: accepted.run_id.clone(), snapshot_id: snapshot_id.to_owned(), knowledge_cutoff: knowledge_cutoff.to_rfc3339(), observation_count, draw_count, excluded_after_cutoff_count: eligible.excluded_after_cutoff_count, indicator_kind: kind.as_str().to_owned(), point_estimate_mean, mean_loading: combined.mean_loading, within_variance: combined.within_variance, between_variance: combined.between_variance, total_variance: combined.total_variance, inference_status: RUBIN_LOADING_INFERENCE_STATUS.into() }; + let artifact = RubinLoadingUncertaintyArtifact { schema_version: RUBIN_LOADING_ARTIFACT_SCHEMA_VERSION.into(), run_id: accepted.run_id.clone(), snapshot_id: snapshot_id.to_owned(), knowledge_cutoff: knowledge_cutoff.to_rfc3339(), observation_count, draw_count, excluded_after_cutoff_count: eligible.excluded_after_cutoff_count, indicator_kind: kind.as_str().to_owned(), estimator_payload_sha256, point_estimate_mean, mean_loading: combined.mean_loading, within_variance: combined.within_variance, between_variance: combined.between_variance, total_variance: combined.total_variance, inference_status: RUBIN_LOADING_INFERENCE_STATUS.into(), projection_status: RUBIN_LOADING_PROJECTION_STATUS.into() }; let digest = artifact.sha256()?; let summary = AnalysisResultSummary::new( "rubin_loading_uncertainty", @@ -376,7 +436,8 @@ mod tests { use super::{ RUBIN_LOADING_ARTIFACT_BYTE_LIMIT, RUBIN_LOADING_ARTIFACT_SCHEMA_VERSION, RUBIN_LOADING_INFERENCE_STATUS, RUBIN_LOADING_MAX_DRAWS, RUBIN_LOADING_MAX_MATRIX_CELLS, - RubinLoadingUncertaintyArtifact, require_artifact_byte_limit, + RUBIN_LOADING_PROJECTION_STATUS, RubinLoadingUncertaintyArtifact, + require_artifact_byte_limit, }; use crate::{AnalysisEngineError, MAX_EVIDENCE_UNITS}; @@ -390,12 +451,15 @@ mod tests { draw_count: 2, excluded_after_cutoff_count: 0, indicator_kind: "alr".into(), + estimator_payload_sha256: + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), point_estimate_mean: 0.8, mean_loading: 0.8, within_variance: 0.0, between_variance: 0.02, total_variance: 0.03, inference_status: RUBIN_LOADING_INFERENCE_STATUS.into(), + projection_status: RUBIN_LOADING_PROJECTION_STATUS.into(), } } @@ -414,6 +478,7 @@ mod tests { RubinLoadingUncertaintyArtifact::from_json(&payload), Ok(artifact.clone()) ); + assert_eq!(artifact.estimator_payload_sha256().len(), 64); assert_eq!(artifact.sha256().expect("digest").len(), 64); assert_eq!( RubinLoadingUncertaintyArtifact::from_json("{}"), @@ -527,6 +592,11 @@ mod tests { value.indicator_kind = "raw_proportion".into(); value }, + { + let mut value = artifact.clone(); + value.estimator_payload_sha256 = "not-a-digest".into(); + value + }, { let mut value = artifact.clone(); value.point_estimate_mean = f64::NAN; @@ -577,6 +647,11 @@ mod tests { value.inference_status.clear(); value }, + { + let mut value = artifact.clone(); + value.projection_status.clear(); + value + }, ]; for invalid in invalid_artifacts { assert_invalid(&invalid); diff --git a/crates/analysis_engine/src/rubin_projection_activation.rs b/crates/analysis_engine/src/rubin_projection_activation.rs new file mode 100644 index 000000000..6396b521c --- /dev/null +++ b/crates/analysis_engine/src/rubin_projection_activation.rs @@ -0,0 +1,913 @@ +//! Fail-closed activation authority for Rubin loading projection. +//! +//! Rubin combination arithmetic remains in `psychometric_core`. This module +//! only decides whether a digest-bound generator/analysis/evidence pairing is +//! authorized to project beyond the descriptive result produced for unbound +//! draw-generation provenance. + +use psychometric_core::IndicatorKind; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; + +use crate::{ + AnalysisEngineError, RUBIN_LOADING_MODEL_CONTRACT_VERSION, format_digest, valid_identifier, +}; + +/// Versioned wire schema for a Rubin projection activation receipt. +pub const RUBIN_PROJECTION_ACTIVATION_RECEIPT_SCHEMA_VERSION: &str = + "tepp.rubin_projection_activation_receipt.v1"; +/// Maximum canonical JSON size accepted for one activation receipt. +pub const RUBIN_PROJECTION_ACTIVATION_RECEIPT_BYTE_LIMIT: usize = 16 * 1024; +/// Exact analysis-contract identity authorized by this projection policy. +pub const RUBIN_LOADING_ANALYSIS_CONTRACT_ID: &str = "rubin_loading_uncertainty"; + +/// Immutable authority receipt offered to the Rubin projection decision. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RubinProjectionActivationReceiptV1 { + schema_version: String, + generator_contract_id: String, + generator_contract_version: String, + analysis_contract_id: String, + analysis_contract_version: String, + validation_evidence_id: String, + validation_evidence_sha256: String, + validation_evidence_available_at: String, + source_snapshot_id: String, + source_snapshot_sha256: String, + knowledge_cutoff: String, + design_envelope_id: String, +} + +impl RubinProjectionActivationReceiptV1 { + /// Construct one canonical activation receipt from immutable contract, + /// source-snapshot, and Validation Evidence references plus typed clocks. + /// + /// `generator_contract` and `analysis_contract` are `(id, version)` pairs. + /// `validation_evidence` is `(id, sha256, available_time)`. The source + /// snapshot is bound by both a stable identity and its canonical SHA-256 so + /// a logical snapshot name cannot authorize different bytes later. + /// Construction validates identity and digest syntax but does not grant + /// authority. Late Validation Evidence remains a valid wire object that the + /// projection decision rejects by cutoff. + /// + /// # Errors + /// + /// Returns a fail-closed validation error when an identifier, immutable + /// authority reference, source snapshot digest, or evidence digest is malformed. + pub fn new( + generator_contract: (&str, &str), + analysis_contract: (&str, &str), + validation_evidence: (&str, &str, AvailableTime), + source_snapshot_id: impl Into, + source_snapshot_sha256: impl Into, + knowledge_cutoff: KnowledgeCutoff, + design_envelope_id: impl Into, + ) -> Result { + let receipt = Self { + schema_version: RUBIN_PROJECTION_ACTIVATION_RECEIPT_SCHEMA_VERSION.into(), + generator_contract_id: generator_contract.0.into(), + generator_contract_version: generator_contract.1.into(), + analysis_contract_id: analysis_contract.0.into(), + analysis_contract_version: analysis_contract.1.into(), + validation_evidence_id: validation_evidence.0.into(), + validation_evidence_sha256: validation_evidence.1.into(), + validation_evidence_available_at: validation_evidence.2.to_rfc3339(), + source_snapshot_id: source_snapshot_id.into(), + source_snapshot_sha256: source_snapshot_sha256.into(), + knowledge_cutoff: knowledge_cutoff.to_rfc3339(), + design_envelope_id: design_envelope_id.into(), + }; + receipt.validate()?; + Ok(receipt) + } + + /// Parse and fully validate bounded receipt JSON. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::LimitExceeded`] before parsing an + /// oversized payload and [`AnalysisEngineError::InvalidEvidence`] for an + /// invalid receipt contract. + pub fn from_json(payload: &str) -> Result { + require_receipt_byte_limit(payload.len())?; + let receipt: Self = + serde_json::from_str(payload).map_err(|_| AnalysisEngineError::InvalidEvidence)?; + receipt.validate()?; + Ok(receipt) + } + + /// Serialize canonical validated receipt JSON. + /// + /// # Errors + /// + /// Returns a validation, size, or serialization failure. + pub fn to_json(&self) -> Result { + self.validate()?; + let payload = + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; + require_receipt_byte_limit(payload.len())?; + Ok(payload) + } + + /// Return the lowercase SHA-256 digest of canonical receipt JSON. + /// + /// # Errors + /// + /// Returns a validation, size, or serialization failure. + pub fn sha256(&self) -> Result { + self.to_json() + .map(|json| format_digest(Sha256::digest(json.into_bytes()))) + } + + /// Return the immutable source snapshot identity bound into the receipt. + #[must_use] + pub fn source_snapshot_id(&self) -> &str { + &self.source_snapshot_id + } + + /// Return the canonical source snapshot SHA-256 bound into the receipt. + #[must_use] + pub fn source_snapshot_sha256(&self) -> &str { + &self.source_snapshot_sha256 + } + + /// Return the canonical knowledge-cutoff wire value. + #[must_use] + pub fn knowledge_cutoff(&self) -> &str { + &self.knowledge_cutoff + } + + fn validate(&self) -> Result<(), AnalysisEngineError> { + self.validate_authority_fields()?; + self.validated_clocks().map(|_| ()) + } + + fn validate_authority_fields(&self) -> Result<(), AnalysisEngineError> { + let immutable_authority_fields = [ + self.generator_contract_id.as_str(), + self.generator_contract_version.as_str(), + self.analysis_contract_id.as_str(), + self.analysis_contract_version.as_str(), + self.validation_evidence_id.as_str(), + self.design_envelope_id.as_str(), + self.source_snapshot_id.as_str(), + ]; + if self.schema_version != RUBIN_PROJECTION_ACTIVATION_RECEIPT_SCHEMA_VERSION + || self.analysis_contract_id != RUBIN_LOADING_ANALYSIS_CONTRACT_ID + || self.analysis_contract_version != RUBIN_LOADING_MODEL_CONTRACT_VERSION + || immutable_authority_fields + .iter() + .any(|value| !valid_identifier(value) || is_mutable_authority_locator(value)) + || !valid_sha256(&self.validation_evidence_sha256) + || !valid_sha256(&self.source_snapshot_sha256) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(()) + } + + fn validated_clocks(&self) -> Result<(AvailableTime, KnowledgeCutoff), AnalysisEngineError> { + let available = AvailableTime::parse_rfc3339(&self.validation_evidence_available_at) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + let cutoff = KnowledgeCutoff::parse_rfc3339(&self.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + if available.to_rfc3339() != self.validation_evidence_available_at + || cutoff.to_rfc3339() != self.knowledge_cutoff + { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok((available, cutoff)) + } +} + +/// Outcome of evaluating a Rubin projection activation receipt. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RubinProjectionActivationDecision { + /// No activation receipt was offered; the artifact remains descriptive. + DescriptiveOnly, + /// An offered receipt failed authority, provenance, design, or registry matching. + Rejected, + /// The exact immutable pairing is approved for the observed design point. + Eligible, +} + +#[derive(Clone, Copy, Debug)] +struct ApprovedRubinProjectionPairing { + generator_contract_id: &'static str, + generator_contract_version: &'static str, + analysis_contract_id: &'static str, + analysis_contract_version: &'static str, + validation_evidence_id: &'static str, + validation_evidence_sha256: &'static str, + validation_evidence_available_at: &'static str, + source_snapshot_id: &'static str, + source_snapshot_sha256: &'static str, + design_envelope_id: &'static str, + supported_observation_counts: &'static [u64], + supported_draw_counts: &'static [u64], + indicator_kind: &'static str, +} + +// Intentionally empty until claim-specific Validation Evidence crosses the +// independent ADR 0014 promotion gate. Draft scientific branches are not +// production authority. +const PRODUCTION_APPROVED_PAIRINGS: &[ApprovedRubinProjectionPairing] = &[]; + +/// Decide whether an optional activation receipt authorizes Rubin projection. +/// +/// Production approval data are deliberately not caller-supplied. The registry +/// remains empty until an independently accepted Validation Evidence package is +/// promoted through the owner path, so a syntactically valid candidate receipt +/// cannot self-authorize. Snapshot identity and content digest are independent +/// runtime inputs and both must match the receipt and owner authority. Runtime +/// observation and draw counts are checked against exact owner-approved design +/// points; a design-envelope label alone never expands discrete validation +/// evidence into an untested range. +#[must_use] +pub fn decide_rubin_projection_activation( + receipt: Option<&RubinProjectionActivationReceiptV1>, + expected_snapshot_id: &str, + expected_snapshot_sha256: &str, + expected_knowledge_cutoff: KnowledgeCutoff, + observation_count: u64, + draw_count: u64, + indicator_kind: IndicatorKind, +) -> RubinProjectionActivationDecision { + decide_with_registry( + receipt, + expected_snapshot_id, + expected_snapshot_sha256, + expected_knowledge_cutoff, + observation_count, + draw_count, + indicator_kind, + PRODUCTION_APPROVED_PAIRINGS, + ) +} + +fn decide_with_registry( + receipt: Option<&RubinProjectionActivationReceiptV1>, + expected_snapshot_id: &str, + expected_snapshot_sha256: &str, + expected_knowledge_cutoff: KnowledgeCutoff, + observation_count: u64, + draw_count: u64, + indicator_kind: IndicatorKind, + approved_pairings: &[ApprovedRubinProjectionPairing], +) -> RubinProjectionActivationDecision { + let Some(receipt) = receipt else { + return RubinProjectionActivationDecision::DescriptiveOnly; + }; + if receipt.validate_authority_fields().is_err() + || !valid_identifier(expected_snapshot_id) + || is_mutable_authority_locator(expected_snapshot_id) + || !valid_sha256(expected_snapshot_sha256) + || observation_count < 2 + || draw_count < 2 + { + return RubinProjectionActivationDecision::Rejected; + } + let Ok((evidence_available_at, receipt_cutoff)) = receipt.validated_clocks() else { + return RubinProjectionActivationDecision::Rejected; + }; + if receipt.source_snapshot_id != expected_snapshot_id + || receipt.source_snapshot_sha256 != expected_snapshot_sha256 + || receipt_cutoff.instant() != expected_knowledge_cutoff.instant() + || evidence_available_at.instant() > expected_knowledge_cutoff.instant() + { + return RubinProjectionActivationDecision::Rejected; + } + + let approved = approved_pairings.iter().any(|pairing| { + let Ok(authoritative_available_at) = + AvailableTime::parse_rfc3339(pairing.validation_evidence_available_at) + else { + return false; + }; + if !valid_identifier(pairing.source_snapshot_id) + || is_mutable_authority_locator(pairing.source_snapshot_id) + || !valid_sha256(pairing.source_snapshot_sha256) + || !valid_design_points(pairing.supported_observation_counts) + || !valid_design_points(pairing.supported_draw_counts) + || authoritative_available_at.to_rfc3339() != pairing.validation_evidence_available_at + || authoritative_available_at.instant() != evidence_available_at.instant() + || authoritative_available_at.instant() > expected_knowledge_cutoff.instant() + { + return false; + } + receipt.generator_contract_id == pairing.generator_contract_id + && receipt.generator_contract_version == pairing.generator_contract_version + && receipt.analysis_contract_id == pairing.analysis_contract_id + && receipt.analysis_contract_version == pairing.analysis_contract_version + && receipt.validation_evidence_id == pairing.validation_evidence_id + && receipt.validation_evidence_sha256 == pairing.validation_evidence_sha256 + && receipt.source_snapshot_id == pairing.source_snapshot_id + && receipt.source_snapshot_sha256 == pairing.source_snapshot_sha256 + && receipt.design_envelope_id == pairing.design_envelope_id + && pairing.supported_observation_counts.contains(&observation_count) + && pairing.supported_draw_counts.contains(&draw_count) + && indicator_kind.as_str() == pairing.indicator_kind + }); + if approved { + RubinProjectionActivationDecision::Eligible + } else { + RubinProjectionActivationDecision::Rejected + } +} + +fn require_receipt_byte_limit(payload_len: usize) -> Result<(), AnalysisEngineError> { + if payload_len > RUBIN_PROJECTION_ACTIVATION_RECEIPT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + Ok(()) +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn valid_design_points(values: &[u64]) -> bool { + !values.is_empty() + && values.iter().all(|value| *value >= 2) + && values.windows(2).all(|pair| pair[0] < pair[1]) +} + +fn is_mutable_authority_locator(value: &str) -> bool { + let normalized = value.to_ascii_lowercase(); + let numeric_alias = ["pr-", "pr/", "pull-", "pull/", "issue-", "issue/"] + .into_iter() + .filter_map(|prefix| normalized.strip_prefix(prefix)) + .any(|suffix| !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())); + let hash_alias = normalized + .strip_prefix('#') + .is_some_and(|suffix| !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())); + + matches!( + normalized.as_str(), + "latest" | "main" | "master" | "latest-release" | "release-latest" + ) || [ + "refs/", + "http://", + "https://", + "git://", + "ssh://", + "github.com/", + ] + .into_iter() + .any(|prefix| normalized.starts_with(prefix)) + || numeric_alias + || hash_alias +} + +#[cfg(test)] +mod tests { + use super::{ + ApprovedRubinProjectionPairing, RUBIN_LOADING_ANALYSIS_CONTRACT_ID, + RUBIN_PROJECTION_ACTIVATION_RECEIPT_BYTE_LIMIT, RubinProjectionActivationDecision, + RubinProjectionActivationReceiptV1, decide_with_registry, + }; + use crate::{AnalysisEngineError, RUBIN_LOADING_MODEL_CONTRACT_VERSION}; + use psychometric_core::IndicatorKind; + use temporal_core::{AvailableTime, KnowledgeCutoff}; + + const SNAPSHOT_ID: &str = "snapshot-rubin-activation"; + const SNAPSHOT_DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const EVIDENCE_ID: &str = "validation-evidence-rubin-approved-v1"; + const EVIDENCE_DIGEST: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + const EVIDENCE_AVAILABLE_AT: &str = "2026-07-31T23:59:59Z"; + const DESIGN_ENVELOPE: &str = "rubin-gaussian-single-level-approved-v1"; + const SUPPORTED_OBSERVATION_COUNTS: &[u64] = &[48, 160]; + const SUPPORTED_DRAW_COUNTS: &[u64] = &[8, 32]; + + const APPROVED: ApprovedRubinProjectionPairing = ApprovedRubinProjectionPairing { + generator_contract_id: "gaussian_complete_data_draws", + generator_contract_version: "approved-v1", + analysis_contract_id: RUBIN_LOADING_ANALYSIS_CONTRACT_ID, + analysis_contract_version: RUBIN_LOADING_MODEL_CONTRACT_VERSION, + validation_evidence_id: EVIDENCE_ID, + validation_evidence_sha256: EVIDENCE_DIGEST, + validation_evidence_available_at: EVIDENCE_AVAILABLE_AT, + source_snapshot_id: SNAPSHOT_ID, + source_snapshot_sha256: SNAPSHOT_DIGEST, + design_envelope_id: DESIGN_ENVELOPE, + supported_observation_counts: SUPPORTED_OBSERVATION_COUNTS, + supported_draw_counts: SUPPORTED_DRAW_COUNTS, + indicator_kind: "alr", + }; + + fn cutoff(value: &str) -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339(value).expect("cutoff") + } + + fn receipt( + available_at: &str, + receipt_cutoff: &str, + ) -> RubinProjectionActivationReceiptV1 { + RubinProjectionActivationReceiptV1::new( + ( + APPROVED.generator_contract_id, + APPROVED.generator_contract_version, + ), + ( + APPROVED.analysis_contract_id, + APPROVED.analysis_contract_version, + ), + ( + APPROVED.validation_evidence_id, + APPROVED.validation_evidence_sha256, + AvailableTime::parse_rfc3339(available_at).expect("availability"), + ), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff(receipt_cutoff), + APPROVED.design_envelope_id, + ) + .expect("receipt") + } + + #[test] + fn exact_fake_pairing_is_eligible_and_equivalent_cutoffs_match_by_instant() { + let receipt = receipt(EVIDENCE_AVAILABLE_AT, "2026-08-01T01:00:00+01:00"); + assert_eq!( + decide_with_registry( + Some(&receipt), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[APPROVED], + ), + RubinProjectionActivationDecision::Eligible + ); + } + + #[test] + fn design_envelope_does_not_generalize_between_validated_points() { + let receipt = receipt(EVIDENCE_AVAILABLE_AT, "2026-08-01T00:00:00Z"); + for (observation_count, draw_count) in [(49, 8), (48, 9), (2, 8), (48, 2)] { + assert_eq!( + decide_with_registry( + Some(&receipt), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + observation_count, + draw_count, + IndicatorKind::AdditiveLogRatio, + &[APPROVED], + ), + RubinProjectionActivationDecision::Rejected + ); + } + + let malformed_points = ApprovedRubinProjectionPairing { + supported_observation_counts: &[48, 48], + ..APPROVED + }; + assert_eq!( + decide_with_registry( + Some(&receipt), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[malformed_points], + ), + RubinProjectionActivationDecision::Rejected + ); + } + + #[test] + fn source_snapshot_digest_must_match_runtime_receipt_and_owner_authority() { + let valid = receipt(EVIDENCE_AVAILABLE_AT, "2026-08-01T00:00:00Z"); + let other_digest = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + assert_eq!( + decide_with_registry( + Some(&valid), + SNAPSHOT_ID, + other_digest, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[APPROVED], + ), + RubinProjectionActivationDecision::Rejected + ); + + let wrong_owner_digest = ApprovedRubinProjectionPairing { + source_snapshot_sha256: other_digest, + ..APPROVED + }; + assert_eq!( + decide_with_registry( + Some(&valid), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[wrong_owner_digest], + ), + RubinProjectionActivationDecision::Rejected + ); + + let malformed_owner_digest = ApprovedRubinProjectionPairing { + source_snapshot_sha256: "not-a-digest", + ..APPROVED + }; + assert_eq!( + decide_with_registry( + Some(&valid), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[malformed_owner_digest], + ), + RubinProjectionActivationDecision::Rejected + ); + } + + #[test] + fn authoritative_evidence_availability_cannot_be_backdated_by_receipt() { + let backdated = receipt("2026-07-31T23:59:59Z", "2026-08-01T00:00:00Z"); + let late_authority = ApprovedRubinProjectionPairing { + validation_evidence_available_at: "2026-08-01T00:00:01Z", + ..APPROVED + }; + assert_eq!( + decide_with_registry( + Some(&backdated), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[late_authority], + ), + RubinProjectionActivationDecision::Rejected + ); + } + + #[test] + fn noncanonical_authority_availability_fails_closed() { + let receipt = receipt(EVIDENCE_AVAILABLE_AT, "2026-08-01T00:00:00Z"); + let noncanonical_authority = ApprovedRubinProjectionPairing { + validation_evidence_available_at: "2026-08-01T00:59:59+01:00", + ..APPROVED + }; + assert_eq!( + decide_with_registry( + Some(&receipt), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[noncanonical_authority], + ), + RubinProjectionActivationDecision::Rejected + ); + } + + #[test] + fn authority_metadata_snapshot_cutoff_and_late_evidence_fail_closed() { + let valid = receipt(EVIDENCE_AVAILABLE_AT, "2026-08-01T00:00:00Z"); + assert_eq!( + decide_with_registry( + Some(&valid), + "", + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[APPROVED], + ), + RubinProjectionActivationDecision::Rejected + ); + assert_eq!( + decide_with_registry( + Some(&valid), + "different-snapshot", + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[APPROVED], + ), + RubinProjectionActivationDecision::Rejected + ); + assert_eq!( + decide_with_registry( + Some(&valid), + SNAPSHOT_ID, + "bad-digest", + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[APPROVED], + ), + RubinProjectionActivationDecision::Rejected + ); + assert_eq!( + decide_with_registry( + Some(&valid), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-02T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[APPROVED], + ), + RubinProjectionActivationDecision::Rejected + ); + let late = receipt("2026-08-01T00:00:01Z", "2026-08-01T00:00:00Z"); + assert_eq!( + decide_with_registry( + Some(&late), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[APPROVED], + ), + RubinProjectionActivationDecision::Rejected + ); + assert_eq!( + decide_with_registry( + Some(&valid), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::IsometricLogRatio, + &[APPROVED], + ), + RubinProjectionActivationDecision::Rejected + ); + } + + #[test] + fn registry_requires_exact_generator_analysis_evidence_digest_and_envelope() { + let valid = receipt(EVIDENCE_AVAILABLE_AT, "2026-08-01T00:00:00Z"); + let mismatches = [ + ApprovedRubinProjectionPairing { + generator_contract_id: "other-generator", + ..APPROVED + }, + ApprovedRubinProjectionPairing { + generator_contract_version: "other-generator-version", + ..APPROVED + }, + ApprovedRubinProjectionPairing { + analysis_contract_id: "other-analysis", + ..APPROVED + }, + ApprovedRubinProjectionPairing { + analysis_contract_version: "other-analysis-version", + ..APPROVED + }, + ApprovedRubinProjectionPairing { + validation_evidence_id: "other-evidence", + ..APPROVED + }, + ApprovedRubinProjectionPairing { + validation_evidence_sha256: + "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + ..APPROVED + }, + ApprovedRubinProjectionPairing { + design_envelope_id: "other-envelope", + ..APPROVED + }, + ]; + for mismatch in mismatches { + assert_eq!( + decide_with_registry( + Some(&valid), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[mismatch], + ), + RubinProjectionActivationDecision::Rejected + ); + } + } + + #[test] + fn approved_validation_evidence_snapshot_must_match_receipt_and_runtime_snapshot() { + let valid = receipt(EVIDENCE_AVAILABLE_AT, "2026-08-01T00:00:00Z"); + let cross_snapshot_authority = ApprovedRubinProjectionPairing { + source_snapshot_id: "snapshot-rubin-other", + ..APPROVED + }; + assert_eq!( + decide_with_registry( + Some(&valid), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[cross_snapshot_authority], + ), + RubinProjectionActivationDecision::Rejected + ); + + let mutable_snapshot_authority = ApprovedRubinProjectionPairing { + source_snapshot_id: "main", + ..APPROVED + }; + assert_eq!( + decide_with_registry( + Some(&valid), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[mutable_snapshot_authority], + ), + RubinProjectionActivationDecision::Rejected + ); + } + + #[test] + fn receipt_wire_refuses_unknown_fields_noncanonical_times_and_mutable_authority() { + let receipt = receipt(EVIDENCE_AVAILABLE_AT, "2026-08-01T00:00:00Z"); + let canonical = receipt.to_json().expect("json"); + let mut unknown: serde_json::Value = serde_json::from_str(&canonical).expect("json"); + unknown["unexpected"] = serde_json::json!(true); + assert_eq!( + RubinProjectionActivationReceiptV1::from_json(&unknown.to_string()), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut noncanonical_cutoff: serde_json::Value = + serde_json::from_str(&canonical).expect("json"); + noncanonical_cutoff["knowledge_cutoff"] = + serde_json::json!("2026-08-01T01:00:00+01:00"); + assert_eq!( + RubinProjectionActivationReceiptV1::from_json(&noncanonical_cutoff.to_string()), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut noncanonical_availability: serde_json::Value = + serde_json::from_str(&canonical).expect("json"); + noncanonical_availability["validation_evidence_available_at"] = + serde_json::json!("2026-08-01T00:59:59+01:00"); + assert_eq!( + RubinProjectionActivationReceiptV1::from_json( + &noncanonical_availability.to_string() + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut mutable: serde_json::Value = serde_json::from_str(&canonical).expect("json"); + mutable["validation_evidence_id"] = serde_json::json!("refs/pull/504/head"); + assert_eq!( + RubinProjectionActivationReceiptV1::from_json(&mutable.to_string()), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut forged_snapshot_digest: serde_json::Value = + serde_json::from_str(&canonical).expect("json"); + forged_snapshot_digest["source_snapshot_sha256"] = serde_json::json!("not-a-digest"); + assert_eq!( + RubinProjectionActivationReceiptV1::from_json(&forged_snapshot_digest.to_string()), + Err(AnalysisEngineError::InvalidEvidence) + ); + } + + #[test] + fn receipt_refuses_invalid_identity_digest_and_private_malformed_state() { + assert_eq!( + RubinProjectionActivationReceiptV1::new( + ("", APPROVED.generator_contract_version), + ( + APPROVED.analysis_contract_id, + APPROVED.analysis_contract_version, + ), + ( + APPROVED.validation_evidence_id, + APPROVED.validation_evidence_sha256, + AvailableTime::parse_rfc3339(EVIDENCE_AVAILABLE_AT) + .expect("availability"), + ), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + APPROVED.design_envelope_id, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + RubinProjectionActivationReceiptV1::new( + ( + APPROVED.generator_contract_id, + APPROVED.generator_contract_version, + ), + ( + APPROVED.analysis_contract_id, + APPROVED.analysis_contract_version, + ), + ( + APPROVED.validation_evidence_id, + "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789", + AvailableTime::parse_rfc3339(EVIDENCE_AVAILABLE_AT) + .expect("availability"), + ), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + APPROVED.design_envelope_id, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + assert_eq!( + RubinProjectionActivationReceiptV1::new( + ( + APPROVED.generator_contract_id, + APPROVED.generator_contract_version, + ), + ( + APPROVED.analysis_contract_id, + APPROVED.analysis_contract_version, + ), + ( + APPROVED.validation_evidence_id, + APPROVED.validation_evidence_sha256, + AvailableTime::parse_rfc3339(EVIDENCE_AVAILABLE_AT) + .expect("availability"), + ), + SNAPSHOT_ID, + "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789", + cutoff("2026-08-01T00:00:00Z"), + APPROVED.design_envelope_id, + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut malformed = receipt(EVIDENCE_AVAILABLE_AT, "2026-08-01T00:00:00Z"); + malformed.knowledge_cutoff = "not-a-time".into(); + assert_eq!( + decide_with_registry( + Some(&malformed), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + cutoff("2026-08-01T00:00:00Z"), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + &[APPROVED], + ), + RubinProjectionActivationDecision::Rejected + ); + } + + #[test] + fn receipt_size_is_checked_before_parse_and_digest_is_stable() { + let oversized = "x".repeat(RUBIN_PROJECTION_ACTIVATION_RECEIPT_BYTE_LIMIT + 1); + assert_eq!( + RubinProjectionActivationReceiptV1::from_json(&oversized), + Err(AnalysisEngineError::LimitExceeded) + ); + let receipt = receipt(EVIDENCE_AVAILABLE_AT, "2026-08-01T00:00:00Z"); + assert_eq!(receipt.sha256().expect("digest").len(), 64); + assert_eq!( + RubinProjectionActivationReceiptV1::from_json(&receipt.to_json().expect("json")), + Ok(receipt) + ); + } +} diff --git a/crates/analysis_engine/src/rubin_projection_draw_authority.rs b/crates/analysis_engine/src/rubin_projection_draw_authority.rs new file mode 100644 index 000000000..223763f2b --- /dev/null +++ b/crates/analysis_engine/src/rubin_projection_draw_authority.rs @@ -0,0 +1,373 @@ +//! Concrete numeric estimator-payload authority for Rubin projection activation. +//! +//! The underlying activation policy owns generator/analysis/evidence approval, +//! temporal provenance, source-snapshot authority, and validated design points. +//! This boundary additionally binds one activation receipt to the exact +//! cutoff-admitted numeric payload consumed by the run: the factor-score design +//! vector and complete-data indicator-draw matrix. A class-level approved +//! generator and a matching matrix shape are not authority for arbitrary values. + +use psychometric_core::IndicatorKind; +use serde::{ + Deserialize, + de::{Error as _, IgnoredAny, MapAccess, Visitor}, +}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fmt; +use temporal_core::{AvailableTime, KnowledgeCutoff}; + +use crate::rubin_projection_activation::{ + RUBIN_PROJECTION_ACTIVATION_RECEIPT_BYTE_LIMIT as INNER_RECEIPT_BYTE_LIMIT, + RUBIN_PROJECTION_ACTIVATION_RECEIPT_SCHEMA_VERSION as INNER_RECEIPT_SCHEMA_VERSION, + RubinProjectionActivationDecision, + RubinProjectionActivationReceiptV1 as InnerRubinProjectionActivationReceiptV1, + decide_rubin_projection_activation as decide_inner_rubin_projection_activation, +}; +use crate::{AnalysisEngineError, format_digest}; + +/// Versioned public wire schema for the payload-bound Rubin activation receipt. +/// +/// This contract is still Draft and has never been released from protected +/// `main`; the per-run payload commitment is therefore part of the eventual v1 +/// definition, not a post-release incompatible mutation. +pub const RUBIN_PROJECTION_ACTIVATION_RECEIPT_SCHEMA_VERSION: &str = INNER_RECEIPT_SCHEMA_VERSION; +/// Maximum canonical JSON size accepted for one payload-bound activation receipt. +pub const RUBIN_PROJECTION_ACTIVATION_RECEIPT_BYTE_LIMIT: usize = INNER_RECEIPT_BYTE_LIMIT; + +/// Payload-bound Rubin projection activation receipt. +/// +/// The nested activation authority remains owned by the existing projection +/// policy. This type adds a canonical SHA-256 commitment to the concrete +/// cutoff-admitted numeric estimator payload. The digest commits both the +/// factor-score design vector and the draw matrix produced by the executor. It +/// prevents a receipt issued for one payload from being replayed for different +/// factor scores or draw values with the same dimensions. It does not, by +/// itself, attest that a caller executed an approved generator or factor-mapping +/// implementation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RubinProjectionActivationReceiptV1 { + inner: InnerRubinProjectionActivationReceiptV1, + estimator_payload_sha256: String, +} + +impl RubinProjectionActivationReceiptV1 { + /// Construct a payload-bound canonical activation receipt. + /// + /// `generator_contract` and `analysis_contract` are `(id, version)` pairs. + /// `validation_evidence` is `(id, sha256, available_time)`. Snapshot and + /// estimator-payload digests are independent commitments and must both be + /// lowercase canonical SHA-256 values. + /// + /// # Errors + /// + /// Returns a fail-closed validation error when the underlying authority + /// receipt or concrete estimator-payload digest is invalid. + pub fn new( + generator_contract: (&str, &str), + analysis_contract: (&str, &str), + validation_evidence: (&str, &str, AvailableTime), + source_snapshot_id: impl Into, + source_snapshot_sha256: impl Into, + estimator_payload_sha256: impl Into, + knowledge_cutoff: KnowledgeCutoff, + design_envelope_id: impl Into, + ) -> Result { + let estimator_payload_sha256 = estimator_payload_sha256.into(); + if !valid_sha256(&estimator_payload_sha256) { + return Err(AnalysisEngineError::InvalidEvidence); + } + let inner = InnerRubinProjectionActivationReceiptV1::new( + generator_contract, + analysis_contract, + validation_evidence, + source_snapshot_id, + source_snapshot_sha256, + knowledge_cutoff, + design_envelope_id, + )?; + Ok(Self { + inner, + estimator_payload_sha256, + }) + } + + /// Parse and fully validate bounded payload-bound receipt JSON. + /// + /// # Errors + /// + /// Returns [`AnalysisEngineError::LimitExceeded`] before parsing an + /// oversized payload and [`AnalysisEngineError::InvalidEvidence`] when the + /// payload commitment, schema version, duplicate authority members, or + /// underlying activation contract is invalid. + pub fn from_json(payload: &str) -> Result { + require_receipt_byte_limit(payload.len())?; + require_unique_top_level_keys(payload)?; + let mut value: Value = + serde_json::from_str(payload).map_err(|_| AnalysisEngineError::InvalidEvidence)?; + let object = value + .as_object_mut() + .ok_or(AnalysisEngineError::InvalidEvidence)?; + if object.get("schema_version").and_then(Value::as_str) + != Some(RUBIN_PROJECTION_ACTIVATION_RECEIPT_SCHEMA_VERSION) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + let estimator_payload_sha256 = object + .remove("estimator_payload_sha256") + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .ok_or(AnalysisEngineError::InvalidEvidence)?; + if !valid_sha256(&estimator_payload_sha256) { + return Err(AnalysisEngineError::InvalidEvidence); + } + let inner_json = + serde_json::to_string(object).map_err(|_| AnalysisEngineError::SerializationFailure)?; + let inner = InnerRubinProjectionActivationReceiptV1::from_json(&inner_json)?; + Ok(Self { + inner, + estimator_payload_sha256, + }) + } + + /// Serialize canonical validated receipt JSON with the estimator-payload commitment. + /// + /// # Errors + /// + /// Returns a validation, size, or serialization failure. + pub fn to_json(&self) -> Result { + if !valid_sha256(&self.estimator_payload_sha256) { + return Err(AnalysisEngineError::InvalidEvidence); + } + let inner_json = self.inner.to_json()?; + let mut value: Value = + serde_json::from_str(&inner_json).map_err(|_| AnalysisEngineError::SerializationFailure)?; + value + .as_object_mut() + .ok_or(AnalysisEngineError::SerializationFailure)? + .insert( + "estimator_payload_sha256".into(), + Value::String(self.estimator_payload_sha256.clone()), + ); + let payload = + serde_json::to_string(&value).map_err(|_| AnalysisEngineError::SerializationFailure)?; + require_receipt_byte_limit(payload.len())?; + Ok(payload) + } + + /// Return the lowercase SHA-256 digest of canonical payload-bound receipt JSON. + /// + /// # Errors + /// + /// Returns a validation, size, or serialization failure. + pub fn sha256(&self) -> Result { + self.to_json() + .map(|json| format_digest(Sha256::digest(json.into_bytes()))) + } + + /// Return the immutable source snapshot identity bound into the receipt. + #[must_use] + pub fn source_snapshot_id(&self) -> &str { + self.inner.source_snapshot_id() + } + + /// Return the canonical source snapshot SHA-256 bound into the receipt. + #[must_use] + pub fn source_snapshot_sha256(&self) -> &str { + self.inner.source_snapshot_sha256() + } + + /// Return the canonical SHA-256 of the exact numeric estimator payload. + #[must_use] + pub fn estimator_payload_sha256(&self) -> &str { + &self.estimator_payload_sha256 + } + + /// Return the canonical knowledge-cutoff wire value. + #[must_use] + pub fn knowledge_cutoff(&self) -> &str { + self.inner.knowledge_cutoff() + } +} + +/// Decide whether a payload-bound receipt authorizes Rubin projection. +/// +/// The runtime estimator-payload digest is independent input. It must be +/// canonical and must match the receipt before the underlying +/// generator/analysis/evidence authority decision is evaluated. Production +/// approval remains controlled exclusively by the underlying owner registry. +#[must_use] +pub fn decide_rubin_projection_activation( + receipt: Option<&RubinProjectionActivationReceiptV1>, + expected_snapshot_id: &str, + expected_snapshot_sha256: &str, + expected_estimator_payload_sha256: &str, + expected_knowledge_cutoff: KnowledgeCutoff, + observation_count: u64, + draw_count: u64, + indicator_kind: IndicatorKind, +) -> RubinProjectionActivationDecision { + let Some(receipt) = receipt else { + return decide_inner_rubin_projection_activation( + None, + expected_snapshot_id, + expected_snapshot_sha256, + expected_knowledge_cutoff, + observation_count, + draw_count, + indicator_kind, + ); + }; + if validated_inner_for_runtime_payload(receipt, expected_estimator_payload_sha256).is_err() { + return RubinProjectionActivationDecision::Rejected; + } + decide_inner_rubin_projection_activation( + Some(&receipt.inner), + expected_snapshot_id, + expected_snapshot_sha256, + expected_knowledge_cutoff, + observation_count, + draw_count, + indicator_kind, + ) +} + +fn validated_inner_for_runtime_payload<'a>( + receipt: &'a RubinProjectionActivationReceiptV1, + expected_estimator_payload_sha256: &str, +) -> Result<&'a InnerRubinProjectionActivationReceiptV1, RubinProjectionActivationDecision> { + if !valid_sha256(expected_estimator_payload_sha256) + || receipt.estimator_payload_sha256 != expected_estimator_payload_sha256 + { + return Err(RubinProjectionActivationDecision::Rejected); + } + Ok(&receipt.inner) +} + +fn require_unique_top_level_keys(payload: &str) -> Result<(), AnalysisEngineError> { + let mut deserializer = serde_json::Deserializer::from_str(payload); + UniqueTopLevelKeys::deserialize(&mut deserializer) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + deserializer + .end() + .map_err(|_| AnalysisEngineError::InvalidEvidence) +} + +struct UniqueTopLevelKeys; + +impl<'de> Deserialize<'de> for UniqueTopLevelKeys { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_map(UniqueTopLevelKeysVisitor) + } +} + +struct UniqueTopLevelKeysVisitor; + +impl<'de> Visitor<'de> for UniqueTopLevelKeysVisitor { + type Value = UniqueTopLevelKeys; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON object with unique top-level member names") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut keys = BTreeSet::new(); + while let Some(key) = map.next_key::()? { + if !keys.insert(key) { + return Err(M::Error::custom("duplicate top-level JSON member")); + } + map.next_value::()?; + } + Ok(UniqueTopLevelKeys) + } +} + +fn valid_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn require_receipt_byte_limit(payload_len: usize) -> Result<(), AnalysisEngineError> { + if payload_len > RUBIN_PROJECTION_ACTIVATION_RECEIPT_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + RUBIN_PROJECTION_ACTIVATION_RECEIPT_SCHEMA_VERSION, RubinProjectionActivationReceiptV1, + validated_inner_for_runtime_payload, + }; + use crate::RUBIN_LOADING_MODEL_CONTRACT_VERSION; + use temporal_core::{AvailableTime, KnowledgeCutoff}; + + const SNAPSHOT_DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const ESTIMATOR_PAYLOAD_DIGEST: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const OTHER_ESTIMATOR_PAYLOAD_DIGEST: &str = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + const EVIDENCE_DIGEST: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn receipt() -> RubinProjectionActivationReceiptV1 { + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "candidate-v1"), + ( + "rubin_loading_uncertainty", + RUBIN_LOADING_MODEL_CONTRACT_VERSION, + ), + ( + "validation-evidence-rubin-candidate-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-07-31T23:59:59Z").expect("availability"), + ), + "snapshot-rubin-activation", + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff"), + "rubin-gaussian-single-level-candidate-v1", + ) + .expect("receipt") + } + + #[test] + fn estimator_payload_gate_rejects_substitution_before_authority_decision() { + let receipt = receipt(); + assert!(validated_inner_for_runtime_payload(&receipt, ESTIMATOR_PAYLOAD_DIGEST).is_ok()); + assert!( + validated_inner_for_runtime_payload(&receipt, OTHER_ESTIMATOR_PAYLOAD_DIGEST).is_err() + ); + assert!(validated_inner_for_runtime_payload(&receipt, "not-a-digest").is_err()); + } + + #[test] + fn estimator_payload_digest_is_part_of_canonical_receipt_and_receipt_digest() { + let receipt = receipt(); + let canonical = receipt.to_json().expect("json"); + let wire: serde_json::Value = serde_json::from_str(&canonical).expect("json"); + assert_eq!( + wire.get("schema_version").and_then(serde_json::Value::as_str), + Some(RUBIN_PROJECTION_ACTIVATION_RECEIPT_SCHEMA_VERSION) + ); + assert!(canonical.contains(ESTIMATOR_PAYLOAD_DIGEST)); + let reparsed = RubinProjectionActivationReceiptV1::from_json(&canonical).expect("receipt"); + assert_eq!(reparsed, receipt); + + let mut changed = wire; + changed["estimator_payload_sha256"] = serde_json::json!(OTHER_ESTIMATOR_PAYLOAD_DIGEST); + let changed = RubinProjectionActivationReceiptV1::from_json(&changed.to_string()) + .expect("alternate valid digest"); + assert_ne!(receipt.sha256().expect("digest"), changed.sha256().expect("digest")); + } +} diff --git a/crates/analysis_engine/tests/rubin_loading_draw_digest_contract.rs b/crates/analysis_engine/tests/rubin_loading_draw_digest_contract.rs new file mode 100644 index 000000000..0e39a237a --- /dev/null +++ b/crates/analysis_engine/tests/rubin_loading_draw_digest_contract.rs @@ -0,0 +1,129 @@ +//! Canonical executor-owned complete-data analysis-payload digest contract. + +use analysis_engine::{ + AnalysisEngineError, RUBIN_LOADING_MODEL_CONTRACT_VERSION, RUBIN_LOADING_OUTPUT_PROFILE, + RubinLoadingObservation, RubinLoadingUncertaintyArtifact, execute_rubin_loading_uncertainty_run, +}; +use psychometric_core::IndicatorKind; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +const SNAPSHOT_ID: &str = "snapshot-rubin-draw-digest"; + +fn available(stamp: &str) -> AvailableTime { + AvailableTime::parse_rfc3339(stamp).expect("availability") +} + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "rubin-draw-digest-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: SNAPSHOT_ID.into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: RUBIN_LOADING_MODEL_CONTRACT_VERSION.into(), + output_profile: RUBIN_LOADING_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new("run-rubin-draw-digest", "accepted", &request.idempotency_key) + .expect("accepted") +} + +fn observation( + factor_score: f64, + draws: Vec, + available_at: &str, +) -> RubinLoadingObservation { + RubinLoadingObservation::new(SNAPSHOT_ID, factor_score, draws, available(available_at)) + .expect("observation") +} + +fn baseline_rows() -> Vec { + vec![ + observation(-1.0, vec![-0.7, -0.9], "2026-07-01T00:00:00Z"), + observation(0.0, vec![0.0, 0.0], "2026-07-01T00:00:00Z"), + observation(1.0, vec![0.7, 0.9], "2026-07-01T00:00:00Z"), + ] +} + +fn execute(rows: &[RubinLoadingObservation]) -> analysis_engine::RubinLoadingUncertaintyExecution { + let request = request(); + let accepted = accepted(&request); + execute_rubin_loading_uncertainty_run( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + IndicatorKind::AdditiveLogRatio, + rows, + "2026-08-02T00:00:00Z", + ) + .expect("execution") +} + +#[test] +fn canonical_payload_digest_is_cutoff_safe_and_value_sensitive() { + let baseline = execute(&baseline_rows()); + let baseline_digest = baseline.artifact.estimator_payload_sha256(); + assert_eq!(baseline_digest.len(), 64); + assert!(baseline_digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())); + + let mut with_late = baseline_rows(); + with_late.push(observation( + 2.0, + vec![100.0, 200.0, 300.0], + "2026-08-15T00:00:00Z", + )); + let replay = execute(&with_late); + assert_eq!( + replay.artifact.estimator_payload_sha256(), + baseline.artifact.estimator_payload_sha256(), + "future-unavailable rows must not change the historical admitted estimator-payload digest" + ); + + let mut changed_draw = baseline_rows(); + changed_draw[2] = observation( + 1.0, + vec![0.700_000_000_000_000_1, 0.9], + "2026-07-01T00:00:00Z", + ); + let changed_draw = execute(&changed_draw); + assert_ne!( + changed_draw.artifact.estimator_payload_sha256(), + baseline.artifact.estimator_payload_sha256(), + "one admitted draw-bit change must change payload identity" + ); + + let mut changed_factor_score = baseline_rows(); + changed_factor_score[2] = observation( + 1.000_000_000_000_000_2, + vec![0.7, 0.9], + "2026-07-01T00:00:00Z", + ); + let changed_factor_score = execute(&changed_factor_score); + assert_ne!( + changed_factor_score.artifact.estimator_payload_sha256(), + baseline.artifact.estimator_payload_sha256(), + "one admitted factor-score bit change must change payload identity because factor scores enter both loading estimators" + ); +} + +#[test] +fn artifact_import_rejects_malformed_estimator_payload_digest() { + let execution = execute(&baseline_rows()); + let canonical = execution.artifact.to_json().expect("artifact json"); + let mut value: serde_json::Value = serde_json::from_str(&canonical).expect("json"); + value["estimator_payload_sha256"] = serde_json::json!("not-a-digest"); + assert_eq!( + RubinLoadingUncertaintyArtifact::from_json(&value.to_string()), + Err(AnalysisEngineError::InvalidRubinLoadingUncertaintyArtifact) + ); +} diff --git a/crates/analysis_engine/tests/rubin_loading_execution_contract.rs b/crates/analysis_engine/tests/rubin_loading_execution_contract.rs index e1b75ac79..cbd2c00f6 100644 --- a/crates/analysis_engine/tests/rubin_loading_execution_contract.rs +++ b/crates/analysis_engine/tests/rubin_loading_execution_contract.rs @@ -261,32 +261,31 @@ fn robust_point_estimate_is_not_replaced_by_naive_rubin_mean() { #[test] fn artifact_refuses_inconsistent_rubin_total_and_unreachable_counts() { - let artifact = RubinLoadingUncertaintyArtifact { - schema_version: RUBIN_LOADING_ARTIFACT_SCHEMA_VERSION.into(), - run_id: "run-rubin-loading".into(), - snapshot_id: SNAPSHOT_ID.into(), - knowledge_cutoff: "2026-08-01T00:00:00Z".into(), - observation_count: 3, - draw_count: 2, - excluded_after_cutoff_count: 0, - indicator_kind: "alr".into(), - point_estimate_mean: 0.8, - mean_loading: 0.8, - within_variance: 0.0, - between_variance: 0.02, - total_variance: 0.04, - inference_status: "rubin_combined_ols_loadings_not_mislevy_pv".into(), - }; + let request = request(); + let accepted = accepted(&request); + let execution = execute( + &request, + &accepted, + SNAPSHOT_ID, + cutoff(), + IndicatorKind::AdditiveLogRatio, + &noiseless_rows(), + ) + .expect("valid artifact"); + let canonical = execution.artifact.to_json().expect("artifact json"); + let mut value: serde_json::Value = serde_json::from_str(&canonical).expect("valid json"); + + value["total_variance"] = serde_json::json!(0.04); assert_eq!( - artifact.to_json(), + RubinLoadingUncertaintyArtifact::from_json(&value.to_string()), Err(AnalysisEngineError::InvalidRubinLoadingUncertaintyArtifact) ); - let mut oversized = artifact; - oversized.total_variance = 0.03; - oversized.observation_count = u64::try_from(MAX_EVIDENCE_UNITS).expect("bound") + 1; + let mut oversized: serde_json::Value = serde_json::from_str(&canonical).expect("valid json"); + oversized["observation_count"] = + serde_json::json!(u64::try_from(MAX_EVIDENCE_UNITS).expect("bound") + 1); assert_eq!( - oversized.to_json(), + RubinLoadingUncertaintyArtifact::from_json(&oversized.to_string()), Err(AnalysisEngineError::InvalidRubinLoadingUncertaintyArtifact) ); } diff --git a/crates/analysis_engine/tests/rubin_loading_projection_policy_contract.rs b/crates/analysis_engine/tests/rubin_loading_projection_policy_contract.rs new file mode 100644 index 000000000..96a5af04f --- /dev/null +++ b/crates/analysis_engine/tests/rubin_loading_projection_policy_contract.rs @@ -0,0 +1,102 @@ +//! Projection-policy contract for Rubin loading uncertainty. +//! +//! Correct Rubin combination arithmetic is not, by itself, evidence that an +//! arbitrary caller-supplied draw matrix belongs to a validated inferential +//! regime. Until draw-generation provenance is bound to approved validation +//! evidence, the product artifact must say that its projection is descriptive. + +use analysis_engine::{ + AnalysisEngineError, RUBIN_LOADING_MODEL_CONTRACT_VERSION, RUBIN_LOADING_OUTPUT_PROFILE, + RubinLoadingObservation, RubinLoadingUncertaintyArtifact, + execute_rubin_loading_uncertainty_run, +}; +use psychometric_core::IndicatorKind; +use temporal_core::{AvailableTime, KnowledgeCutoff}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +const SNAPSHOT_ID: &str = "snapshot-rubin-projection-policy"; +const CUTOFF: &str = "2026-08-01T00:00:00Z"; +const DESCRIPTIVE_ONLY: &str = "descriptive_only_unbound_draw_generation_provenance"; + +fn observation(factor_score: f64, draws: Vec) -> RubinLoadingObservation { + RubinLoadingObservation::new( + SNAPSHOT_ID, + factor_score, + draws, + AvailableTime::parse_rfc3339("2026-07-01T00:00:00Z").expect("availability"), + ) + .expect("observation") +} + +fn execute() -> analysis_engine::RubinLoadingUncertaintyExecution { + let request = AnalysisRunRequest { + contract_version: 1, + idempotency_key: "rubin-projection-policy-idem".into(), + tenant_workspace_id: "tenant-rubin-projection-policy".into(), + snapshot_id: SNAPSHOT_ID.into(), + knowledge_cutoff: CUTOFF.into(), + model_contract_version: RUBIN_LOADING_MODEL_CONTRACT_VERSION.into(), + output_profile: RUBIN_LOADING_OUTPUT_PROFILE.into(), + }; + let accepted = AnalysisRunAccepted::new( + "run-rubin-projection-policy", + "accepted", + &request.idempotency_key, + ) + .expect("accepted"); + let rows = vec![ + observation(-1.0, vec![-0.7, -0.9]), + observation(0.0, vec![0.0, 0.0]), + observation(1.0, vec![0.7, 0.9]), + ]; + + execute_rubin_loading_uncertainty_run( + &request, + &accepted, + SNAPSHOT_ID, + KnowledgeCutoff::parse_rfc3339(CUTOFF).expect("cutoff"), + IndicatorKind::AdditiveLogRatio, + &rows, + "2026-08-02T00:00:00Z", + ) + .expect("descriptive combination remains executable") +} + +#[test] +fn provenance_free_draws_are_explicitly_descriptive_only() { + let execution = execute(); + + assert_eq!(execution.artifact.projection_status(), DESCRIPTIVE_ONLY); + let artifact_json: serde_json::Value = + serde_json::from_str(&execution.artifact.to_json().expect("artifact json")) + .expect("valid json"); + assert_eq!( + artifact_json + .get("projection_status") + .and_then(serde_json::Value::as_str), + Some(DESCRIPTIVE_ONLY) + ); +} + +#[test] +fn projection_policy_wire_refuses_missing_or_forged_status() { + let execution = execute(); + let canonical = execution.artifact.to_json().expect("artifact json"); + + let mut missing: serde_json::Value = serde_json::from_str(&canonical).expect("valid json"); + missing + .as_object_mut() + .expect("artifact object") + .remove("projection_status"); + assert_eq!( + RubinLoadingUncertaintyArtifact::from_json(&missing.to_string()), + Err(AnalysisEngineError::InvalidRubinLoadingUncertaintyArtifact) + ); + + let mut forged: serde_json::Value = serde_json::from_str(&canonical).expect("valid json"); + forged["projection_status"] = serde_json::json!("validated_rubin_inference"); + assert_eq!( + RubinLoadingUncertaintyArtifact::from_json(&forged.to_string()), + Err(AnalysisEngineError::InvalidRubinLoadingUncertaintyArtifact) + ); +} diff --git a/crates/analysis_engine/tests/rubin_projection_activation_contract.rs b/crates/analysis_engine/tests/rubin_projection_activation_contract.rs new file mode 100644 index 000000000..968bd81b9 --- /dev/null +++ b/crates/analysis_engine/tests/rubin_projection_activation_contract.rs @@ -0,0 +1,185 @@ +//! Fail-closed authority contract for Rubin inferential projection. +//! +//! A valid-looking receipt is not sufficient authority: production approval +//! requires an immutable pairing that is independently registered by the +//! Validation Evidence owner path. Until that registry contains a pairing, the +//! decision remains descriptive-only or rejected without changing Rubin +//! arithmetic. + +use analysis_engine::{ + RUBIN_LOADING_MODEL_CONTRACT_VERSION, RubinProjectionActivationDecision, + RubinProjectionActivationReceiptV1, decide_rubin_projection_activation, +}; +use psychometric_core::IndicatorKind; +use temporal_core::{AvailableTime, KnowledgeCutoff}; + +const SNAPSHOT_ID: &str = "snapshot-rubin-activation"; +const SNAPSHOT_DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ESTIMATOR_PAYLOAD_DIGEST: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const CUTOFF: &str = "2026-08-01T00:00:00Z"; +const EVIDENCE_DIGEST: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const OBSERVATION_COUNT: u64 = 48; +const DRAW_COUNT: u64 = 8; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339(CUTOFF).expect("cutoff") +} + +fn receipt() -> RubinProjectionActivationReceiptV1 { + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "candidate-v1"), + ( + "rubin_loading_uncertainty", + RUBIN_LOADING_MODEL_CONTRACT_VERSION, + ), + ( + "validation-evidence-rubin-candidate-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-07-31T23:59:59Z").expect("availability"), + ), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + "rubin-gaussian-single-level-candidate-v1", + ) + .expect("receipt") +} + +#[test] +fn noncanonical_analysis_identity_is_not_admitted_by_rubin_receipt() { + let evidence = ( + "validation-evidence-rubin-candidate-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-07-31T23:59:59Z").expect("availability"), + ); + + assert!( + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "candidate-v1"), + ("different_analysis_contract", RUBIN_LOADING_MODEL_CONTRACT_VERSION), + evidence.clone(), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + "rubin-gaussian-single-level-candidate-v1", + ) + .is_err() + ); + assert!( + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "candidate-v1"), + ("rubin_loading_uncertainty", "noncanonical-version"), + evidence, + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + "rubin-gaussian-single-level-candidate-v1", + ) + .is_err() + ); +} + +#[test] +fn no_receipt_remains_descriptive_only() { + assert_eq!( + decide_rubin_projection_activation( + None, + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + OBSERVATION_COUNT, + DRAW_COUNT, + IndicatorKind::AdditiveLogRatio, + ), + RubinProjectionActivationDecision::DescriptiveOnly + ); +} + +#[test] +fn production_registry_does_not_preapprove_candidate_evidence() { + assert_eq!( + decide_rubin_projection_activation( + Some(&receipt()), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + OBSERVATION_COUNT, + DRAW_COUNT, + IndicatorKind::AdditiveLogRatio, + ), + RubinProjectionActivationDecision::Rejected + ); +} + +#[test] +fn receipt_wire_is_bounded_digest_bound_and_canonical() { + let receipt = receipt(); + let json = receipt.to_json().expect("receipt json"); + let reparsed = RubinProjectionActivationReceiptV1::from_json(&json).expect("receipt parse"); + + assert_eq!(reparsed, receipt); + assert_eq!(reparsed.knowledge_cutoff(), CUTOFF); + assert_eq!(reparsed.source_snapshot_id(), SNAPSHOT_ID); + assert_eq!(reparsed.source_snapshot_sha256(), SNAPSHOT_DIGEST); + assert_eq!( + reparsed.estimator_payload_sha256(), + ESTIMATOR_PAYLOAD_DIGEST + ); + assert_eq!(reparsed.sha256().expect("digest").len(), 64); +} + +#[test] +fn receipt_wire_refuses_forged_digest_and_late_evidence() { + let canonical = receipt().to_json().expect("receipt json"); + let mut forged: serde_json::Value = serde_json::from_str(&canonical).expect("json"); + forged["validation_evidence_sha256"] = serde_json::json!("not-a-digest"); + assert!(RubinProjectionActivationReceiptV1::from_json(&forged.to_string()).is_err()); + + let mut forged_snapshot: serde_json::Value = serde_json::from_str(&canonical).expect("json"); + forged_snapshot["source_snapshot_sha256"] = serde_json::json!("not-a-digest"); + assert!(RubinProjectionActivationReceiptV1::from_json(&forged_snapshot.to_string()).is_err()); + + let mut forged_payload: serde_json::Value = serde_json::from_str(&canonical).expect("json"); + forged_payload["estimator_payload_sha256"] = serde_json::json!("not-a-digest"); + assert!(RubinProjectionActivationReceiptV1::from_json(&forged_payload.to_string()).is_err()); + + let late = RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "candidate-v1"), + ( + "rubin_loading_uncertainty", + RUBIN_LOADING_MODEL_CONTRACT_VERSION, + ), + ( + "validation-evidence-rubin-candidate-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-08-01T00:00:01Z").expect("availability"), + ), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + "rubin-gaussian-single-level-candidate-v1", + ) + .expect("syntactically valid late receipt"); + assert_eq!( + decide_rubin_projection_activation( + Some(&late), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + OBSERVATION_COUNT, + DRAW_COUNT, + IndicatorKind::AdditiveLogRatio, + ), + RubinProjectionActivationDecision::Rejected + ); +} diff --git a/crates/analysis_engine/tests/rubin_projection_design_envelope_contract.rs b/crates/analysis_engine/tests/rubin_projection_design_envelope_contract.rs new file mode 100644 index 000000000..45e34ada9 --- /dev/null +++ b/crates/analysis_engine/tests/rubin_projection_design_envelope_contract.rs @@ -0,0 +1,78 @@ +//! Runtime design-envelope contract for Rubin projection activation. +//! +//! A design-envelope identifier is not evidence that the current run lies in +//! the validated design. Activation therefore receives the actual observation +//! and draw counts independently of the receipt. The concrete draw-payload +//! digest is also independent runtime input. Production remains fail closed +//! while the approved-pairing registry is empty. + +use analysis_engine::{ + RUBIN_LOADING_MODEL_CONTRACT_VERSION, RubinProjectionActivationDecision, + RubinProjectionActivationReceiptV1, decide_rubin_projection_activation, +}; +use psychometric_core::IndicatorKind; +use temporal_core::{AvailableTime, KnowledgeCutoff}; + +const SNAPSHOT_ID: &str = "snapshot-rubin-activation"; +const SNAPSHOT_DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DRAW_PAYLOAD_DIGEST: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const EVIDENCE_DIGEST: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn receipt() -> RubinProjectionActivationReceiptV1 { + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "candidate-v1"), + ( + "rubin_loading_uncertainty", + RUBIN_LOADING_MODEL_CONTRACT_VERSION, + ), + ( + "validation-evidence-rubin-candidate-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-07-31T23:59:59Z").expect("availability"), + ), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + DRAW_PAYLOAD_DIGEST, + cutoff(), + "rubin-gaussian-single-level-candidate-v1", + ) + .expect("receipt") +} + +#[test] +fn production_activation_receives_actual_runtime_design_dimensions() { + assert_eq!( + decide_rubin_projection_activation( + Some(&receipt()), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + DRAW_PAYLOAD_DIGEST, + cutoff(), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + ), + RubinProjectionActivationDecision::Rejected + ); + + assert_eq!( + decide_rubin_projection_activation( + Some(&receipt()), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + DRAW_PAYLOAD_DIGEST, + cutoff(), + 49, + 8, + IndicatorKind::AdditiveLogRatio, + ), + RubinProjectionActivationDecision::Rejected + ); +} diff --git a/crates/analysis_engine/tests/rubin_projection_draw_payload_authority_contract.rs b/crates/analysis_engine/tests/rubin_projection_draw_payload_authority_contract.rs new file mode 100644 index 000000000..9b049b61a --- /dev/null +++ b/crates/analysis_engine/tests/rubin_projection_draw_payload_authority_contract.rs @@ -0,0 +1,101 @@ +//! Concrete estimator-payload provenance contract for Rubin projection activation. +//! +//! Snapshot identity, design dimensions, and an approved generator class do not +//! identify the actual factor-score/design vector and complete-data draw matrix +//! consumed by one run. Activation therefore binds the receipt to the canonical +//! SHA-256 of that concrete numeric payload and receives the independently +//! computed runtime digest. + +use analysis_engine::{ + RUBIN_LOADING_MODEL_CONTRACT_VERSION, RubinProjectionActivationDecision, + RubinProjectionActivationReceiptV1, decide_rubin_projection_activation, +}; +use psychometric_core::IndicatorKind; +use temporal_core::{AvailableTime, KnowledgeCutoff}; + +const SNAPSHOT_ID: &str = "snapshot-rubin-activation"; +const SNAPSHOT_DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ESTIMATOR_PAYLOAD_DIGEST: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const OTHER_ESTIMATOR_PAYLOAD_DIGEST: &str = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const EVIDENCE_DIGEST: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn receipt() -> RubinProjectionActivationReceiptV1 { + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "candidate-v1"), + ( + "rubin_loading_uncertainty", + RUBIN_LOADING_MODEL_CONTRACT_VERSION, + ), + ( + "validation-evidence-rubin-candidate-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-07-31T23:59:59Z").expect("availability"), + ), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + "rubin-gaussian-single-level-candidate-v1", + ) + .expect("receipt") +} + +#[test] +fn receipt_wire_binds_the_concrete_estimator_payload() { + let receipt = receipt(); + assert_eq!(receipt.estimator_payload_sha256(), ESTIMATOR_PAYLOAD_DIGEST); + + let json = receipt.to_json().expect("receipt json"); + let reparsed = RubinProjectionActivationReceiptV1::from_json(&json).expect("receipt parse"); + assert_eq!( + reparsed.estimator_payload_sha256(), + ESTIMATOR_PAYLOAD_DIGEST + ); + + let mut forged: serde_json::Value = serde_json::from_str(&json).expect("json"); + forged["estimator_payload_sha256"] = serde_json::json!("not-a-digest"); + assert!(RubinProjectionActivationReceiptV1::from_json(&forged.to_string()).is_err()); +} + +#[test] +fn production_decision_receives_runtime_estimator_payload_digest_independently() { + let receipt = receipt(); + + // Production remains rejected because the approved-pairing registry is + // intentionally empty, but the API must carry the concrete runtime digest + // independently so a future approved pairing cannot authorize substitution. + assert_eq!( + decide_rubin_projection_activation( + Some(&receipt), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + ), + RubinProjectionActivationDecision::Rejected + ); + assert_eq!( + decide_rubin_projection_activation( + Some(&receipt), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + OTHER_ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + 48, + 8, + IndicatorKind::AdditiveLogRatio, + ), + RubinProjectionActivationDecision::Rejected + ); +} diff --git a/crates/analysis_engine/tests/rubin_projection_duplicate_key_contract.rs b/crates/analysis_engine/tests/rubin_projection_duplicate_key_contract.rs new file mode 100644 index 000000000..0683614ef --- /dev/null +++ b/crates/analysis_engine/tests/rubin_projection_duplicate_key_contract.rs @@ -0,0 +1,77 @@ +use analysis_engine::{ + AnalysisEngineError, RUBIN_LOADING_MODEL_CONTRACT_VERSION, + RubinProjectionActivationReceiptV1, +}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; + +const SNAPSHOT_DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ESTIMATOR_PAYLOAD_DIGEST: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const OTHER_ESTIMATOR_PAYLOAD_DIGEST: &str = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const EVIDENCE_DIGEST: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const OTHER_EVIDENCE_DIGEST: &str = + "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + +fn receipt() -> RubinProjectionActivationReceiptV1 { + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "candidate-v1"), + ( + "rubin_loading_uncertainty", + RUBIN_LOADING_MODEL_CONTRACT_VERSION, + ), + ( + "validation-evidence-rubin-candidate-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-07-31T23:59:59Z").expect("availability"), + ), + "snapshot-rubin-activation", + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff"), + "rubin-gaussian-single-level-candidate-v1", + ) + .expect("receipt") +} + +fn inject_duplicate_member(canonical: &str, field: &str, first: &str, second: &str) -> String { + let unique_member = format!("\"{field}\":\"{second}\""); + let duplicate_members = format!("\"{field}\":\"{first}\",\"{field}\":\"{second}\""); + let ambiguous = canonical.replacen(&unique_member, &duplicate_members, 1); + assert_ne!(ambiguous, canonical, "fixture must inject the duplicate member"); + ambiguous +} + +#[test] +fn duplicate_estimator_payload_digest_member_is_rejected_before_authority_interpretation() { + let canonical = receipt().to_json().expect("canonical receipt"); + let ambiguous = inject_duplicate_member( + &canonical, + "estimator_payload_sha256", + OTHER_ESTIMATOR_PAYLOAD_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + ); + + assert_eq!( + RubinProjectionActivationReceiptV1::from_json(&ambiguous), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn duplicate_nested_authority_member_is_rejected_before_inner_receipt_reparse() { + let canonical = receipt().to_json().expect("canonical receipt"); + let ambiguous = inject_duplicate_member( + &canonical, + "validation_evidence_sha256", + OTHER_EVIDENCE_DIGEST, + EVIDENCE_DIGEST, + ); + + assert_eq!( + RubinProjectionActivationReceiptV1::from_json(&ambiguous), + Err(AnalysisEngineError::InvalidEvidence) + ); +} diff --git a/crates/analysis_engine/tests/rubin_projection_mutable_authority_contract.rs b/crates/analysis_engine/tests/rubin_projection_mutable_authority_contract.rs new file mode 100644 index 000000000..afee006ec --- /dev/null +++ b/crates/analysis_engine/tests/rubin_projection_mutable_authority_contract.rs @@ -0,0 +1,89 @@ +//! Regression contract for mutable Rubin projection authority locators. +//! +//! Activation authority is digest-bound scientific provenance. Mutable Git +//! branch, pull-request, issue, repository-locator, or latest-release aliases +//! must never be admitted as immutable contract/evidence identities. + +use analysis_engine::{ + AnalysisEngineError, RUBIN_LOADING_MODEL_CONTRACT_VERSION, + RubinProjectionActivationReceiptV1, +}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; + +const EVIDENCE_DIGEST: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const SNAPSHOT_DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const DRAW_PAYLOAD_DIGEST: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn canonical_receipt_json() -> String { + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "candidate-v1"), + ("rubin_loading_uncertainty", RUBIN_LOADING_MODEL_CONTRACT_VERSION), + ( + "validation-evidence-rubin-candidate-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-07-31T23:59:59Z").expect("availability"), + ), + "snapshot-rubin-activation", + SNAPSHOT_DIGEST, + DRAW_PAYLOAD_DIGEST, + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff"), + "rubin-gaussian-single-level-candidate-v1", + ) + .expect("receipt") + .to_json() + .expect("json") +} + +#[test] +fn mutable_authority_aliases_fail_closed_on_public_wire() { + let canonical = canonical_receipt_json(); + let mutable_aliases = [ + "Latest", + "PR-504", + "pull/504", + "issue-505", + "#504", + "github.com/ContextualWisdomLab/TEPP/pull/504", + "git://github.com/ContextualWisdomLab/TEPP.git", + "ssh://git@github.com/ContextualWisdomLab/TEPP.git", + "latest-release", + "release-latest", + ]; + + for mutable_alias in mutable_aliases { + let mut payload: serde_json::Value = + serde_json::from_str(&canonical).expect("canonical json"); + payload["validation_evidence_id"] = serde_json::json!(mutable_alias); + assert_eq!( + RubinProjectionActivationReceiptV1::from_json(&payload.to_string()), + Err(AnalysisEngineError::InvalidEvidence), + "mutable authority alias must fail closed: {mutable_alias}" + ); + } +} + +#[test] +fn immutable_scientific_identifiers_remain_admissible_without_keyword_heuristics() { + let canonical = canonical_receipt_json(); + let immutable_identifiers = [ + "validation-evidence-rubin-candidate-v1", + "validation-evidence-latest-model-v1", + "main-effect-loading-model-v1", + "pr-", + "pr-model-v1", + "issue-analysis-v1", + ]; + + for immutable_identifier in immutable_identifiers { + let mut payload: serde_json::Value = + serde_json::from_str(&canonical).expect("canonical json"); + payload["validation_evidence_id"] = serde_json::json!(immutable_identifier); + assert!( + RubinProjectionActivationReceiptV1::from_json(&payload.to_string()).is_ok(), + "non-locator scientific identifier must remain admissible: {immutable_identifier}" + ); + } +} diff --git a/crates/analysis_engine/tests/rubin_projection_snapshot_authority_contract.rs b/crates/analysis_engine/tests/rubin_projection_snapshot_authority_contract.rs new file mode 100644 index 000000000..c84278912 --- /dev/null +++ b/crates/analysis_engine/tests/rubin_projection_snapshot_authority_contract.rs @@ -0,0 +1,96 @@ +//! Regression contract for immutable source-snapshot authority in Rubin projection. +//! +//! A projection receipt claims to bind an immutable source snapshot. Mutable +//! branch and pull-request locators therefore cannot be admitted as snapshot +//! identities even when the expected snapshot argument repeats the same alias. +//! Snapshot identity alone is not a content commitment, so the receipt also +//! carries the canonical source snapshot SHA-256. Concrete estimator inputs have +//! a separate digest and do not weaken snapshot authority. + +use analysis_engine::{ + AnalysisEngineError, RUBIN_LOADING_MODEL_CONTRACT_VERSION, + RubinProjectionActivationReceiptV1, +}; +use temporal_core::{AvailableTime, KnowledgeCutoff}; + +const EVIDENCE_DIGEST: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const SNAPSHOT_DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const ESTIMATOR_PAYLOAD_DIGEST: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn receipt_with_snapshot( + snapshot_id: &str, +) -> Result { + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "candidate-v1"), + ( + "rubin_loading_uncertainty", + RUBIN_LOADING_MODEL_CONTRACT_VERSION, + ), + ( + "validation-evidence-rubin-candidate-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-07-31T23:59:59Z").expect("availability"), + ), + snapshot_id, + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff"), + "rubin-gaussian-single-level-candidate-v1", + ) +} + +#[test] +fn mutable_snapshot_aliases_fail_closed_at_receipt_construction() { + for mutable_snapshot in [ + "main", + "master", + "latest", + "refs/heads/main", + "PR-504", + "pull/504", + "#504", + "github.com/ContextualWisdomLab/TEPP/tree/main", + ] { + assert_eq!( + receipt_with_snapshot(mutable_snapshot), + Err(AnalysisEngineError::InvalidEvidence), + "mutable source snapshot must fail closed: {mutable_snapshot}" + ); + } +} + +#[test] +fn immutable_snapshot_identifier_and_digest_remain_admissible() { + let receipt = receipt_with_snapshot("snapshot-rubin-activation-v1") + .expect("immutable snapshot identifier"); + assert_eq!(receipt.source_snapshot_id(), "snapshot-rubin-activation-v1"); + assert_eq!(receipt.source_snapshot_sha256(), SNAPSHOT_DIGEST); + assert_eq!(receipt.estimator_payload_sha256(), ESTIMATOR_PAYLOAD_DIGEST); +} + +#[test] +fn malformed_snapshot_digest_fails_closed_at_receipt_construction() { + assert_eq!( + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "candidate-v1"), + ( + "rubin_loading_uncertainty", + RUBIN_LOADING_MODEL_CONTRACT_VERSION, + ), + ( + "validation-evidence-rubin-candidate-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-07-31T23:59:59Z").expect("availability"), + ), + "snapshot-rubin-activation-v1", + "not-a-digest", + ESTIMATOR_PAYLOAD_DIGEST, + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff"), + "rubin-gaussian-single-level-candidate-v1", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} diff --git a/crates/analysis_engine/tests/rubin_projection_snapshot_digest_authority_contract.rs b/crates/analysis_engine/tests/rubin_projection_snapshot_digest_authority_contract.rs new file mode 100644 index 000000000..3ec3bdbb8 --- /dev/null +++ b/crates/analysis_engine/tests/rubin_projection_snapshot_digest_authority_contract.rs @@ -0,0 +1,117 @@ +//! Contract for content-addressed Rubin projection snapshot authority. + +use analysis_engine::{ + AnalysisEngineError, RUBIN_LOADING_ANALYSIS_CONTRACT_ID, RUBIN_LOADING_MODEL_CONTRACT_VERSION, + RubinProjectionActivationDecision, RubinProjectionActivationReceiptV1, + decide_rubin_projection_activation, +}; +use psychometric_core::IndicatorKind; +use temporal_core::{AvailableTime, KnowledgeCutoff}; + +const SNAPSHOT_ID: &str = "snapshot-rubin-activation"; +const SNAPSHOT_DIGEST: &str = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const OTHER_SNAPSHOT_DIGEST: &str = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const ESTIMATOR_PAYLOAD_DIGEST: &str = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +const EVIDENCE_DIGEST: &str = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const OBSERVATION_COUNT: u64 = 48; +const DRAW_COUNT: u64 = 8; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn receipt() -> RubinProjectionActivationReceiptV1 { + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "approved-v1"), + ( + RUBIN_LOADING_ANALYSIS_CONTRACT_ID, + RUBIN_LOADING_MODEL_CONTRACT_VERSION, + ), + ( + "validation-evidence-rubin-approved-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-07-31T23:59:59Z").expect("availability"), + ), + SNAPSHOT_ID, + SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + "rubin-gaussian-single-level-approved-v1", + ) + .expect("receipt") +} + +#[test] +fn receipt_binds_canonical_source_snapshot_digest_into_wire_and_digest() { + let receipt = receipt(); + assert_eq!(receipt.source_snapshot_id(), SNAPSHOT_ID); + assert_eq!(receipt.source_snapshot_sha256(), SNAPSHOT_DIGEST); + assert_eq!(receipt.estimator_payload_sha256(), ESTIMATOR_PAYLOAD_DIGEST); + + let json = receipt.to_json().expect("json"); + assert!(json.contains(SNAPSHOT_DIGEST)); + assert!(json.contains(ESTIMATOR_PAYLOAD_DIGEST)); + assert_eq!( + RubinProjectionActivationReceiptV1::from_json(&json), + Ok(receipt) + ); +} + +#[test] +fn receipt_refuses_noncanonical_source_snapshot_digest() { + assert_eq!( + RubinProjectionActivationReceiptV1::new( + ("gaussian_complete_data_draws", "approved-v1"), + ( + RUBIN_LOADING_ANALYSIS_CONTRACT_ID, + RUBIN_LOADING_MODEL_CONTRACT_VERSION, + ), + ( + "validation-evidence-rubin-approved-v1", + EVIDENCE_DIGEST, + AvailableTime::parse_rfc3339("2026-07-31T23:59:59Z").expect("availability"), + ), + SNAPSHOT_ID, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + "rubin-gaussian-single-level-approved-v1", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn runtime_snapshot_digest_is_an_independent_activation_input() { + let receipt = receipt(); + assert_eq!( + decide_rubin_projection_activation( + None, + SNAPSHOT_ID, + OTHER_SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + OBSERVATION_COUNT, + DRAW_COUNT, + IndicatorKind::AdditiveLogRatio, + ), + RubinProjectionActivationDecision::DescriptiveOnly + ); + assert_eq!( + decide_rubin_projection_activation( + Some(&receipt), + SNAPSHOT_ID, + OTHER_SNAPSHOT_DIGEST, + ESTIMATOR_PAYLOAD_DIGEST, + cutoff(), + OBSERVATION_COUNT, + DRAW_COUNT, + IndicatorKind::AdditiveLogRatio, + ), + RubinProjectionActivationDecision::Rejected + ); +} diff --git a/docs/adr/0034-rubin-loading-uncertainty-analysis-run.md b/docs/adr/0034-rubin-loading-uncertainty-analysis-run.md index f82ff2bea..8a7d0723a 100644 --- a/docs/adr/0034-rubin-loading-uncertainty-analysis-run.md +++ b/docs/adr/0034-rubin-loading-uncertainty-analysis-run.md @@ -1,11 +1,11 @@ # ADR 0034 — Rubin loading uncertainty as an analysis-run output profile -**Decision status:** Proposed -**Implementation maturity:** active-PR — composed on this branch; not implemented-main -**Date:** 2026-08-31 -**Last reviewed:** 2026-09-14 -**Supersedes:** None; complements ADR 0005 (ESEM/DSEM interpretation), ADR 0014 (scientific claim promotion), 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. +**Decision status:** Proposed +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-08-31 +**Last reviewed:** 2026-09-15 +**Supersedes:** None; complements ADR 0005 (ESEM/DSEM interpretation), ADR 0014 (scientific claim promotion), 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 @@ -28,64 +28,226 @@ components, and terminal provider validation reused the scientific inference label. A second scientific boundary remains after those application repairs. The -current input can carry any finite complete-data draw matrix; it does not yet -identify the generator/imputer contract or validation evidence for the exact -generator/analysis pairing. Rubin-style variance validity is therefore not -implied by the algebra alone. The profile may calculate the bounded component -arithmetic, but broader inferential activation must remain scoped to a -validated draw-generation design until that provenance contract exists. +current input can carry any finite complete-data draw matrix; formula correctness +does not identify the generator/imputer contract or establish that its pairing +with this analysis has accepted recovery/coverage evidence. Broader inferential +activation must therefore remain scoped to an immutable, validated +generator/analysis/evidence pairing. + +A third temporal authority constraint applies to that pairing. Validation +Evidence `AvailableTime` cannot be trusted only because a caller placed it in an +otherwise valid activation receipt. Positive authority must bind the exact +owner-controlled availability instant for the approved Validation Evidence +identity/digest; otherwise a later evidence package could be backdated into an +older `KnowledgeCutoff`. + +A fourth provenance constraint applies to the snapshot name itself. A field +called `source_snapshot_id` is not immutable merely because it is syntactically +valid. Branch names, pull-request aliases, repository locators, and latest-style +refs can move after an artifact is produced. They therefore cannot serve as +historical projection authority even when caller and executor currently resolve +the same alias. + +A fifth contract-identity constraint applies before registry matching. This +receipt type is specifically the authority object for +`rubin_loading_uncertainty`; an owner-controlled pairing registry may approve a +generator/evidence/design combination but must not be able to redefine the +analysis contract or version that the receipt authorizes. + +A sixth provenance constraint applies to the owner-controlled pairing itself. +Receipt-to-run snapshot equality proves which population the current execution +names, but it does not prove that the approved Validation Evidence was generated +for that same immutable population. The approved pairing must therefore bind its +own immutable source snapshot and require exact agreement with the receipt/run +snapshot before positive projection can be eligible. + +A seventh provenance constraint applies to the bytes behind that snapshot +identity. A stable logical `source_snapshot_id` can still be reused for changed +content. TEPP already uses canonical `source_snapshot_sha256` commitments in +other temporal/projection contracts. Rubin projection authority therefore also +requires a canonical SHA-256 to match independently across runtime input, the +activation receipt, and the owner-controlled approved Validation Evidence +pairing. Snapshot-name equality without content equality is not immutable +scientific provenance. + +An eighth provenance constraint applies to the concrete complete-data draw +matrix itself. Snapshot identity/content, generator class, design-envelope ID, +actual observation count, and actual draw count still do not identify the +matrix values consumed by one run. Two matrices can satisfy all of those fields +and differ only in values. Issue #515 therefore requires the public activation +receipt to commit to the canonical SHA-256 of the exact per-run numeric payload +and the runtime decision to receive that digest independently. The digest +prevents payload substitution after receipt issuance; it is not, by itself, +proof that the matching bytes were generated by the approved generator. + +A ninth provenance constraint determines who owns those bytes. A per-run hash is +not reproducible authority if every consumer is free to choose JSON spelling, +row orientation, pre-cutoff rows, or another serialization. Issue #516 therefore +makes the Rubin executor the canonical payload-hash owner after snapshot/cutoff +admission and transposition, over the numeric inputs passed to +`psychometric_core`. The representation is domain-separated, binds admitted +observation/draw counts, and encodes finite `f64` values by exact IEEE-754 bits +in fixed byte order. This solves payload identity, not generator-execution +attestation. + +A tenth provenance constraint closes issue #522. Both protected loading +estimators consume the already-mapped `factor_scores` vector in addition to the +complete-data indicator-draw matrix. A draw-only digest therefore permits a +factor-score substitution that can change the OLS loading result while leaving +the receipt's payload commitment unchanged. The executor-owned commitment must +cover the cutoff-admitted factor-score vector and draw matrix together. Source +snapshot hashing does not substitute for this invariant because the executor +accepts the mapped factor score as a numeric application input and does not +prove the source-to-score mapping. Because these schemas remain unreleased, +`estimator_payload_sha256` is the eventual v1 wire/accessor name rather than +preserving the narrower branch-local draw-only label. ## Decision Add `rubin_loading_uncertainty_v1` to `analysis_engine` as an application -composition over the protected-main scientific owners. The executor: +composition over the protected-main scientific owners. The executor and +projection boundary: -- requires every `RubinLoadingObservation` to carry the requested immutable +- require every `RubinLoadingObservation` to carry the requested immutable `snapshot_id` and typed `AvailableTime`; -- rejects cross-snapshot observations and excludes same-snapshot observations +- reject cross-snapshot observations and exclude same-snapshot observations with `AvailableTime > KnowledgeCutoff` before matrix/scientific admission; -- parses request cutoffs and compares temporal instants, while persisted +- parse request cutoffs and compare temporal instants, while persisted artifacts retain one canonical RFC 3339 cutoff representation; -- invokes `recover_loading_point_estimate_mean` for the robust point estimate +- invoke `recover_loading_point_estimate_mean` for the robust point estimate and `combine_draw_level_ols_loadings` independently for Rubin `Q̄/Ū/B/T`; -- limits the current application representation to at most 256 complete-data +- limit the current application representation to at most 256 complete-data draws and 1,000,000 admitted observation-by-draw cells before transposition. These are resource envelopes, not psychometric validity recommendations; -- bounds total raw observation population with `MAX_EVIDENCE_UNITS` and makes +- bound total raw observation population with `MAX_EVIDENCE_UNITS` and make imported artifact counts obey the same reachable envelope; -- validates imported `T` by recomputing the exact binary64 expression used by +- after cutoff/snapshot admission and draw-major transposition, compute + `estimator_payload_sha256` over **both** the admitted factor-score vector and + the exact draw matrix sent to `psychometric_core`; +- start that commitment with the domain separator + `tepp.rubin_loading.analysis_payload.v1\0`, then admitted observation and draw + counts as big-endian `u64`, then each finite factor score's exact IEEE-754 bits + as big-endian bytes in admitted observation order, then each finite draw + value's exact IEEE-754 bits as big-endian bytes in draw-major/admitted- + observation order. Future-unavailable rows are absent from all payload bytes; +- bind that canonical lowercase SHA-256 into the Rubin artifact behind a + read-only accessor and reject imported artifacts whose digest is malformed or + noncanonical. The artifact's own SHA-256 therefore commits to all concrete + admitted numeric estimator inputs in addition to its statistical summaries; +- validate imported `T` by recomputing the exact binary64 expression used by the scientific owner. Canonical JSON round-tripping preserves the component values, so exact equality is the chosen wire-integrity policy rather than a tolerance that could admit a different scientific result; -- applies the 256 KiB artifact envelope to both untrusted `from_json` and +- apply the 256 KiB artifact envelope to both untrusted `from_json` and canonical `to_json`, with a maximal-valid escaping proof for the output direction; -- propagates artifact/digest errors instead of asserting that accepted request +- propagate artifact/digest errors instead of asserting that accepted request identifiers make serialization infallible; -- emits terminal `AnalysisResultSummary.validation_status = "validated"` and - keeps `rubin_combined_ols_loadings_not_mislevy_pv` solely as the artifact's - scientific inference boundary. +- emit terminal `AnalysisResultSummary.validation_status = "validated"` and + keep `rubin_combined_ols_loadings_not_mislevy_pv` solely as the artifact's + scientific inference boundary; +- emit the digest-bound, read-only projection status + `descriptive_only_unbound_draw_generation_provenance` whenever the draw + generator is not bound to claim-specific Validation Evidence. Callers cannot + mutate that status through the public artifact API; +- define a bounded public `RubinProjectionActivationReceiptV1` that binds + generator contract ID/version, exact Rubin analysis contract ID/version, + Validation Evidence ID/SHA-256/availability, source snapshot identity/SHA-256, + **executor-owned `estimator_payload_sha256`**, knowledge cutoff, and design + envelope; +- keep the receipt schema at `tepp.rubin_projection_activation_receipt.v1` + because this contract is Draft, has never shipped from protected `main`, and + has no immutable TEPP release. The estimator-payload commitment and precise + name are part of the eventual v1 definition rather than a post-release + incompatible change; +- validate the receipt's analysis ID/version against the canonical + `RUBIN_LOADING_ANALYSIS_CONTRACT_ID` and + `RUBIN_LOADING_MODEL_CONTRACT_VERSION` before registry matching. Production + registry data may narrow approved pairings but cannot redefine the analysis + semantics of this receipt type; +- treat the receipt snapshot and the expected Analysis Run snapshot as + immutable authority fields. Activation rejects mutable branch/PR/issue/ + repository/latest locator shapes before identity comparison; matching the + same mutable alias on both sides is not sufficient provenance; +- validate the receipt and runtime `source_snapshot_sha256` as canonical + lowercase 64-hex digests and require exact equality before registry matching; +- validate the receipt and executor-produced runtime + `estimator_payload_sha256` as canonical lowercase 64-hex digests and require + exact equality before class-level registry matching. A factor-score or + draw-value substitution fails closed even when snapshot, cutoff, dimensions, + design envelope, indicator, generator, analysis, and evidence identities all + remain unchanged; +- receive actual runtime observation and draw counts independently and require + exact membership in the owner-approved design points; a design-envelope label + never authorizes interpolation or extrapolation; +- evaluate the class-level receipt through a pure fail-closed activation + decision after the payload gate. Snapshot identity/digest and cutoff binding + precede registry matching, generator/analysis/evidence/design/indicator + matching is exact rather than heuristic, and the approved pairing itself + carries the canonical Validation Evidence `AvailableTime` plus the immutable + source snapshot identity/digest covered by that evidence; +- reject positive activation unless the receipt availability equals that + owner-controlled availability by typed instant and the authoritative + availability is at or before the run cutoff. Noncanonical registry clocks + fail closed; +- reject positive activation when the owner-controlled evidence snapshot ID is + malformed/mutable, when its digest is malformed, or when either identity or + digest differs from the receipt/runtime snapshot. Cross-snapshot or same-name + different-content Validation Evidence is not portable scientific authority; +- keep the production approved-pairing registry empty on this Draft branch. + A test-private fake pairing exercises the class-level `Eligible` branch + without creating production claim authority; the public payload gate + separately proves that substitution is rejected before that decision. This remains draw-level OLS combination. It is not person-level plausible-value pooling, an ESEM/DSEM sampler, CWC, persistence, or a causal estimator. -The current profile also does not yet authorize arbitrary supplied draw sets -as generally valid multiple-imputation inference. Issue #505 owns the required -versioned draw-generation/analysis provenance and claim-projection policy. An -approved implementation must bind the generator/model contract and the exact -validation-evidence identity for that pairing without copying external source -truth into TEPP. Until then, `Q̄/Ū/B/T` may be computed descriptively while any -broader inferential promotion remains fail closed outside the explicitly -validated design envelope. +The profile does not authorize arbitrary supplied draw sets as generally valid +multiple-imputation inference. Issue #505 owns claim-specific positive +activation. Draft #506 owns both the negative projection rule and the bounded +activation authority contract, #515 closes concrete payload substitution, #516 +makes the executor the canonical payload-hash owner, and #522 repairs the +factor-score omission plus the unreleased public payload name. No positive +artifact projection is emitted because no production pairing has crossed ADR +0014's scientific, review, and release gates. Until an approved pairing exists, +`Q̄/Ū/B/T` may be computed descriptively but cannot be projected as validated +interval/variance inference. -## Historical replay invariant +## Historical replay and payload invariant For a fixed requested snapshot and knowledge cutoff, adding evidence that only becomes available after that cutoff must not change the earlier admitted -factor-score/draw matrix or its scientific result. Such rows may change only -the excluded-after-cutoff count. A row from another immutable snapshot is not -historical censoring; it is a provenance violation and fails closed even when -its availability is later than the cutoff. +factor-score/draw matrix, its canonical `estimator_payload_sha256`, or its +scientific result. Such rows may change only the excluded-after-cutoff count. A +row from another immutable snapshot is not historical censoring; it is a +provenance violation and fails closed even when its availability is later than +the cutoff. + +Activation authority follows the same temporal rule. Validation Evidence that +was unavailable at the historical cutoff cannot retroactively authorize an +older run. The availability clock is owner-controlled approval metadata, not a +caller assertion: matching the approved evidence ID/digest while supplying an +earlier receipt timestamp fails closed. Equivalent RFC 3339 spellings of the +same instant compare equal only after typed parsing; persisted receipt and +registry timestamps remain canonical. + +Snapshot provenance is part of replay authority. `main`, `refs/heads/main`, PR +numbers, repository tree URLs, and equivalent mutable locators cannot identify +the historical source population of an authoritative projection. A stable +snapshot identity is necessary but not sufficient: the receipt and expected +execution also carry a canonical source snapshot SHA-256, and both identity and +digest must equal the source population recorded by the approved Validation +Evidence pairing. + +Concrete numeric estimator content is a separate invariant. Observation/draw +counts describe shape, not values. The executor, not an arbitrary caller, owns +the canonical hash representation of the admitted factor-score vector and +draw-major matrix. The public receipt binds `estimator_payload_sha256` and +activation receives the executor-produced runtime value independently. +Receipt/runtime mismatch is rejected before class-level approval. This prevents +reusing a receipt for different factor scores or draw values with an identical +shape; it does not certify that a matching payload actually came from the +approved generator or factor-mapping implementation. ## Alternatives considered @@ -110,6 +272,57 @@ its availability is later than the cutoff. sources — rejected as neither realistic nor necessary. Bind each supported generator/analysis pairing to versioned provenance and claim-specific validation evidence instead. +9. Pre-authorize Draft #504's generator from a branch-local test result — + rejected because its exact-head workflows and independent approval are not + complete. Activation evidence cannot be promoted by the same branch that is + still establishing it. +10. Accept a caller-supplied approval registry — rejected because a consumer + could self-authorize arbitrary draw generators. Production approval data + remain an owner-controlled immutable registry, while tests use a private + fake registry only to exercise the pure decision algorithm. +11. Trust caller-supplied Validation Evidence availability once ID/digest match — + rejected because immutable content identity does not establish when that + evidence became available. The owner-controlled pairing binds the + authoritative availability clock and historical eligibility uses that clock. +12. Accept a mutable snapshot alias when receipt and executor strings match — + rejected because string equality does not establish immutable source + identity. A branch or PR ref can later resolve to different evidence while + preserving the same text, breaking historical replay and auditability. +13. Let each approved registry entry choose an arbitrary analysis contract — + rejected because registry content is configuration authority over approved + pairings, not semantic authority over the Rubin receipt type. The canonical + analysis ID/version is enforced before any registry lookup. +14. Treat receipt-to-run snapshot equality as sufficient even when the approved + Validation Evidence pairing has no snapshot binding — rejected because an + evidence digest validated for snapshot A could otherwise be replayed against + immutable snapshot B while preserving every other approved field. +15. Treat a stable `source_snapshot_id` as a content commitment — rejected + because the same logical ID can be rebound to different bytes. Owner, + receipt, and runtime must share the exact canonical source snapshot SHA-256. +16. Treat exact observation/draw counts plus an approved design-envelope label + as identity for the matrix itself — rejected because equal dimensions do not + imply equal numeric inputs. The concrete per-run payload receives its own + digest. +17. Put one fixed per-run payload digest into the approved-pairing registry — + rejected because the registry approves a validated generator/analysis/ + evidence/design class, while each run has its own concrete payload. The + per-run receipt/runtime boundary owns the payload commitment. +18. Treat a matching payload digest as proof that the approved generator + executed — rejected. A digest proves content equality only. Generator- + execution attestation, if required, must come from a trusted generator owner + contract rather than being inferred from a caller-provided content hash. +19. Let each caller choose its own payload serialization before hashing — + rejected because the same estimator input could receive different digests + and the same digest label could be detached from cutoff admission. + `analysis_engine` hashes the exact admitted input with one domain-separated + binary contract instead. +20. Hash only the indicator-draw matrix — rejected because both loading + estimators also consume `factor_scores`; keeping factor scores outside the + commitment permits a result-changing substitution under an unchanged + receipt digest. +21. Preserve the unreleased draw-only wire member as a compatibility alias — + rejected because there is no immutable release to preserve and retaining a + misleading public name would make the eventual v1 contract less precise. ## Scientific acceptance boundary @@ -128,48 +341,72 @@ new immutable snapshot and each executor call is a distinct Analysis Run. A rolling-origin comparison may reuse one replication-specific snapshot because the early and late views refer to the same generated population, but those views still use distinct run/idempotency identities. Reusing one snapshot or -accepted-run receipt across different generated populations is invalid -evidence even when the resulting numerical summaries are deterministic. +accepted-run receipt across different generated populations is invalid evidence +even when the resulting numerical summaries are deterministic. Issue #505 separately owns inferential activation. The Analysis Run must not project #504's design-specific coverage as universal authorization for an unknown or arbitrary draw generator. Promotion requires a versioned approved -generator/analysis pairing and the validation evidence that supports that +generator/analysis pairing and the exact Validation Evidence identity/digest, +owner-controlled availability, immutable source snapshot identity/SHA-256, +cutoff, exact validated design points, and design envelope that support that pairing, consistent with ADR 0014's separation of implementation authority from -scientific/product claim authority. +scientific/product claim authority. Each activated run additionally binds the +executor-produced concrete estimator-payload digest. Draft #506 can represent +and reject this authority boundary but cannot populate the production registry +from its own candidate evidence. Primary authorities for the current combining-rule and activation boundary are: -Rubin, D. B. (1987). *Multiple Imputation for Nonresponse in Surveys*. Wiley. +Rubin, D. B. (1987). *Multiple Imputation for Nonresponse in Surveys*. Wiley. https://doi.org/10.1002/9780470316696 Rubin, D. B. (1996). Multiple imputation after 18+ years. *Journal of the -American Statistical Association, 91*(434), 473–489. +American Statistical Association, 91*(434), 473–489. https://doi.org/10.1080/01621459.1996.10476908 Meng, X.-L. (1994). Multiple-imputation inferences with uncongenial sources of -input. *Statistical Science, 9*(4), 538–558. +input. *Statistical Science, 9*(4), 538–558. https://doi.org/10.1214/ss/1177010269 Xie, X., & Meng, X.-L. (2017). Dissecting multiple imputation from a multi-phase inference perspective: What happens when God's, imputer's and analyst's models -are uncongenial? *Statistica Sinica, 27*(4), 1485–1545. +are uncongenial? *Statistica Sinica, 27*(4), 1485–1594. https://doi.org/10.5705/ss.2014.067 Repository research authority is `docs/research/rubin-total-variance.md` plus -`docs/research/rubin-loading-uncertainty-scientific-acceptance.md` for the -current profile-level repeated-sampling evidence. +`docs/research/rubin-loading-uncertainty-scientific-acceptance.md` and +`docs/research/rubin-loading-projection-activation-contract.md` for the current +profile-level evidence and authority boundary. ## Consequences The profile has a narrower, auditable temporal and resource boundary, and its artifact can no longer claim a Rubin total inconsistent with its serialized components. Consumers can distinguish provider validation from the scientific -claim boundary and can distinguish the robust point estimate from Rubin `Q̄`. -The checked-in #504 evidence can support the declared generator/design without -silently promoting arbitrary draw sources. The profile remains Draft/Proposed -and not implemented-main while #503, #505, and the normal exact-head merge -gates remain unresolved. +claim boundary, distinguish the robust point estimate from Rubin `Q̄`, and +distinguish descriptive combination arithmetic from projection authority. The +artifact now also carries an executor-produced identity for every admitted +numeric input to the loading estimators, so a later activation receipt need not +depend on consumer-defined matrix/design-vector serialization. + +The activation receipt has an executable bounded representation and class-level +pure decision algorithm, including the positive algorithmic branch under a +private test pairing. Positive eligibility requires owner-controlled evidence +availability, immutable owner snapshot identity/content, exact validated design +points, and a public per-run estimator-payload commitment. Issue #509 prevents +mutable receipt/run snapshot refs; #510 fixes the canonical Rubin analysis +identity; #511 binds owner-approved Validation Evidence to its source snapshot +identity; #512 adds the source-content commitment; #515 adds concrete per-run +payload binding; #516 makes the executor/artifact the canonical digest owner; +#522 repairs the missing factor-score design-vector commitment and gives the +unreleased v1 payload its precise public name. + +That does not make #504 or #506 scientific authority: the production approved- +pairing registry is empty, the artifact remains descriptive-only, and exact-head +scientific/review/release gates still control promotion. The profile remains +Draft/Proposed and not implemented-main while #503, #505, and the normal exact- +head merge gates remain unresolved. ## Verification @@ -185,16 +422,32 @@ python3 scripts/validate_documentation.py Regression contracts cover equivalent cutoff instants, future-evidence replay, cross-snapshot refusal, robust-point versus naive-mean cancellation, exact and exceeded draw/resource bounds, inconsistent Rubin totals, artifact count -bounds, terminal provider/domain-status separation, and Monte Carlo -snapshot/run-identity non-aliasing. #505 must add an executable activation -contract distinguishing missing/unknown draw-generation provenance from an -explicitly approved versioned generator/analysis pairing; that RED must not -duplicate the Rubin arithmetic. +bounds, terminal provider/domain-status separation, Monte Carlo snapshot/run- +identity non-aliasing, digest-bound descriptive-only projection, bounded receipt +round-trip/digest validation, missing receipt, late Validation Evidence, +snapshot/cutoff mismatch, mutable snapshot aliases, public refusal of a +noncanonical Rubin analysis ID/version, unknown or mismatched generator/analysis/ +evidence/design/indicator authority, caller backdating versus owner-controlled +evidence availability, noncanonical registry availability, owner-approved +snapshot mismatch/mutable-snapshot refusal, same-ID different-source-digest +refusal, malformed receipt/owner/runtime source snapshot digest refusal, exact +runtime design-point admission, malformed estimator-payload digest refusal, +receipt/runtime estimator-payload mismatch refusal, cutoff-safe executor payload +digest stability, admitted draw-value sensitivity, admitted factor-score +sensitivity, and malformed artifact estimator-payload digest refusal. + +A future positive artifact state still requires an independently promoted +production pairing and must digest-bind the activation receipt. Predecessor +checks, Draft review state, or mutable PR/branch locators do not transfer as +claim authority. ## Rollback and supersession -Rollback removes the `rubin_loading_uncertainty_v1` profile. No persisted -schema migration is introduced. Supersede only with an ADR that preserves the -scientific owner split, temporal provenance, resource admission, claim-specific -validation evidence, and the Rubin-versus-Mislevy / draw-generation activation -boundaries. +Rollback removes the `rubin_loading_uncertainty_v1` profile and its activation +policy. No persisted schema migration is introduced. Supersede only with an ADR +that preserves the scientific owner split, temporal provenance, immutable +receipt/run snapshot identity **and content digest**, owner-controlled Validation +Evidence snapshot identity/digest authority, executor-owned concrete numeric +estimator-payload identity, exact runtime design-point admission, resource +admission, claim-specific validation evidence, canonical analysis identity, and +the Rubin-versus-Mislevy / draw-generation activation boundaries. \ No newline at end of file diff --git a/docs/research/rubin-loading-projection-activation-contract.md b/docs/research/rubin-loading-projection-activation-contract.md new file mode 100644 index 000000000..f0929b344 --- /dev/null +++ b/docs/research/rubin-loading-projection-activation-contract.md @@ -0,0 +1,160 @@ +# Rubin loading projection activation contract + +Status: branch-local scientific design and implementation evidence for issue #505, including source-snapshot authority repairs #511/#512, exact runtime-design work on the current #506 head, concrete payload authority #515, executor-owned canonical hashing #516, and factor-score design-payload repair #522. This document does not authorize inferential activation and does not promote Draft #504 to scientific/product claim authority. + +## Problem + +`rubin_loading_uncertainty_v1` can correctly compute the bounded descriptive quantities `Qbar`, `Ubar`, `B`, and `T` for finite complete-data indicator draws. The arithmetic does not identify how those draws were generated, whether the draw generator is compatible with the estimand/analysis procedure, or which claim-specific Validation Evidence supports an inferential projection. + +Rubin-style variance validity is therefore not inferred from the combining formula alone. Rubin (1996) ties multiple-imputation inference to the imputation procedure, while Meng (1994) and Xie and Meng (2017) show that imputer/analyst uncongeniality can change variance and coverage behavior. The supported unit of activation in TEPP is a specific generator/analysis/evidence pairing applied to a specific immutable source population and concrete estimator payload, not an arbitrary finite matrix that merely has an approved shape. For the current loading profile that estimator payload contains both the cutoff-admitted factor-score design vector and the complete-data indicator-draw matrix. + +## Owner boundary + +- `psychometric_core` owns reusable `Qbar/Ubar/B/T` and robust loading-point arithmetic. +- `analysis_engine` owns request admission, snapshot/cutoff composition, Validation Evidence binding, canonical estimator-payload hashing, and projection policy. +- Validation Evidence supplies claim-specific recovery/coverage evidence for one declared design envelope and one immutable source snapshot. It does not become estimator code. +- LLM output is not numerical, scientific-acceptance, or activation authority. +- No external generator source is copied into TEPP. A generator is consumed only through an immutable versioned contract identity plus evidence provenance. +- An estimator-payload SHA-256 is a content commitment, not generator-execution or factor-mapping attestation. It prevents receipt reuse against different admitted numeric inputs after issuance. If the trusted generator/mapping boundary cannot issue or attest the digest-bound payload, execution attestation remains a separate owner gap rather than an inferred property of the digest. + +## Implemented receipt and executor payload identity + +Draft #506 exposes `RubinProjectionActivationReceiptV1` as a bounded immutable canonical-JSON value object with these fields: + +```text +schema_version +generator_contract_id +generator_contract_version +analysis_contract_id +analysis_contract_version +validation_evidence_id +validation_evidence_sha256 +validation_evidence_available_at +source_snapshot_id +source_snapshot_sha256 +estimator_payload_sha256 +knowledge_cutoff +design_envelope_id +``` + +The public payload-bound boundary wraps the internal class-level activation receipt. The internal class-level decision is not re-exported as the product boundary; public callers receive the payload-bound receipt and decision. The receipt is limited to 16 KiB before parsing. Identifiers use the Analysis Run identifier bound. Validation Evidence SHA-256, source-snapshot SHA-256, and estimator-payload SHA-256 are exactly 64 lowercase hexadecimal characters. `validation_evidence_available_at` and `knowledge_cutoff` are stored canonically after typed parsing. Receipt SHA-256 is computed from the validated canonical JSON, so all three content commitments participate in the public receipt digest. + +Issue #516 made the Rubin executor the canonical hash owner, and #522 corrected the committed bytes after finding that both loading estimators consume `factor_scores` as well as `indicator_draws`. Because the receipt and artifact schemas are still Draft and have never shipped from protected `main`, #522 also corrects the public wire/accessor name before release: the commitment is `estimator_payload_sha256`, not the narrower historical branch-local draw-only label. + +The digest is computed **after snapshot/cutoff admission and transposition, before numerical combination**, over every cutoff-admitted numeric input passed to `psychometric_core`. The byte contract is versioned by the domain separator `tepp.rubin_loading.analysis_payload.v1\0`, then binds admitted observation count and draw count as fixed big-endian `u64`; each admitted factor score follows as its exact IEEE-754 `f64` bit pattern in admitted observation order; then each finite indicator-draw value follows as its exact IEEE-754 bit pattern in big-endian draw-major/admitted-observation order. This avoids locale/JSON formatting ambiguity, preserves estimator input order, makes a one-bit factor-score or draw-value change visible, and keeps rows excluded because `AvailableTime > KnowledgeCutoff` out of the commitment. The resulting lowercase SHA-256 is stored read-only in the digest-bound Rubin artifact and is the runtime value intended for the activation decision. + +The receipt schema is still `tepp.rubin_projection_activation_receipt.v1`. This is not a mutation of a released wire contract: the receipt remains Draft, has never shipped from protected `main`, and TEPP has no immutable release containing it. The estimator-payload commitment and precise name are therefore part of the eventual v1 definition rather than a post-release compatibility change. The Rubin artifact schema likewise remains Draft/not released. + +Because this receipt type is specifically the Rubin loading projection authority, its validation boundary requires `analysis_contract_id == rubin_loading_uncertainty` and the exact current `RUBIN_LOADING_MODEL_CONTRACT_VERSION`. A future approved-pairing registry can select generator/evidence/design combinations, but it cannot redefine which analysis semantics the receipt authorizes. + +`source_snapshot_id` must equal the Analysis Run snapshot and must itself be immutable authority. Activation-specific validation rejects branch, pull-request, issue, repository, and latest-release locator shapes for the snapshot exactly as it does for generator/analysis/evidence/design authority. A caller cannot make `main` or `refs/heads/main` scientific provenance merely by supplying the same alias as `expected_snapshot_id`. + +Snapshot identity is not a content commitment. The receipt therefore also binds `source_snapshot_sha256`, and runtime activation requires an independently supplied expected source-snapshot SHA-256. Positive eligibility requires exact equality among the runtime digest, receipt digest, and owner-controlled approved pairing digest. Reusing the same logical snapshot ID for different bytes is a provenance failure. + +Likewise, an approved design-envelope label plus `observation_count`/`draw_count` is not the identity of the actual estimator input. The receipt binds `estimator_payload_sha256`, and the public activation decision receives that executor-produced runtime digest independently. A mismatch or malformed runtime digest is rejected before class-level generator/analysis/evidence approval is evaluated. This closes payload substitution in which two runs share the same snapshot, cutoff, dimensions, indicator, and approved generator class but differ either in already-mapped factor scores or complete-data draw values. + +The activation receipt does not replace row-level `AvailableTime`; both the source observations and the Validation Evidence itself must have been available at or before the run's `KnowledgeCutoff`. + +The receipt's `validation_evidence_available_at`, `source_snapshot_id`, and `source_snapshot_sha256` are transport metadata, not self-authenticating authority. Positive class-level eligibility requires the owner-controlled approved pairing to carry the canonical availability instant and the exact immutable source snapshot identity/digest covered by that Validation Evidence ID/digest. The decision rejects a receipt that attempts to backdate the evidence clock, reuse the same approved evidence package against another snapshot, or reuse the same snapshot name with different content. + +## Activation decision + +The public `decide_rubin_projection_activation` is a fail-closed composition over the payload-bound receipt, expected snapshot identity/digest, independently supplied expected estimator-payload digest, cutoff, actual observation/draw counts, and indicator kind. The production approval registry is not caller-supplied and is intentionally empty on this Draft branch. + +The decision order is: + +1. no receipt -> `DescriptiveOnly`; +2. require a canonical runtime `estimator_payload_sha256` and exact equality with the receipt payload digest; a mismatch rejects before class-level approval; +3. validate the bounded class-level receipt contract, require the canonical Rubin analysis ID/version, and require immutable locator-safe authority fields, including the source snapshot identity and canonical source snapshot SHA-256; +4. require the expected Analysis Run snapshot to be an immutable locator-safe identifier, require a canonical expected source snapshot SHA-256, and require exact receipt/runtime snapshot identity and digest equality; +5. compare receipt/run knowledge cutoffs by instant, not RFC 3339 spelling; +6. parse the owner-controlled approved Validation Evidence availability, require canonical form, and require the receipt availability to equal that authoritative instant; +7. require the authoritative Validation Evidence availability to be at or before the Analysis Run cutoff; +8. require the owner-controlled approved source snapshot identity to be immutable and locator-safe, require its source snapshot SHA-256 to be canonical, and require exact identity/digest equality with the receipt/runtime snapshot; +9. require an exact immutable generator contract ID/version; +10. require the exact Rubin analysis contract ID/version again at registry pairing rather than allowing registry metadata to alter receipt semantics; +11. require exact Validation Evidence identity and SHA-256; +12. require the actual runtime observation count and draw count to be exact owner-approved design points; a design-envelope label never interpolates between points; +13. require exact design envelope and indicator-kind coverage; +14. only an exact approved class-level pairing reached through the estimator-payload gate can return `Eligible`; otherwise return `Rejected`. + +A positive artifact state is not emitted by #506. The current production registry contains no approved pairing, so a valid candidate receipt cannot self-authorize. A bare string such as `validated_rubin_inference` remains insufficient. + +## Required refusal matrix + +| Case | Required result | +| --- | --- | +| no activation receipt | descriptive-only | +| malformed receipt/evidence digest/snapshot digest/payload digest/time | fail closed | +| runtime estimator-payload digest differs from receipt digest | fail closed before class-level approval | +| admitted factor-score vector changes while all draw values/dimensions remain equal | payload digest changes; old receipt cannot authorize | +| admitted draw value changes while factor scores/dimensions remain equal | payload digest changes; old receipt cannot authorize | +| future-unavailable factor-score/draw row is added | historical payload digest unchanged | +| syntactically valid but noncanonical Rubin analysis ID/version | fail closed at receipt admission | +| unknown generator ID/version | fail closed | +| known generator with wrong analysis contract/version | fail closed | +| correct pairing with unknown Validation Evidence ID | fail closed | +| correct evidence ID with digest mismatch | fail closed | +| receipt backdates or changes owner-controlled Validation Evidence availability | fail closed | +| approved-registry Validation Evidence availability is noncanonical | fail closed | +| authoritative evidence availability is after the run cutoff | fail closed | +| receipt snapshot identity different from the Analysis Run snapshot | fail closed | +| same snapshot identity but receipt/runtime source snapshot SHA-256 differs | fail closed | +| approved Validation Evidence snapshot identity or SHA-256 different from the receipt/runtime snapshot | fail closed | +| approved-registry source snapshot SHA-256 is malformed/noncanonical | fail closed | +| approved-registry snapshot is a mutable branch/PR/issue/repository/latest locator | fail closed | +| receipt or expected snapshot is a mutable branch/PR/issue/repository/latest locator | fail closed | +| mutable branch/PR identity presented as approval | fail closed because it has no approved immutable pairing | +| actual runtime observation/draw counts are not exact approved points | fail closed | +| design envelope or indicator-kind mismatch | fail closed | +| exact estimator-payload digest + exact approved pairing + exact evidence digest + authoritative cutoff-safe availability + exact immutable evidence snapshot identity/SHA-256 | eligible in the composed pure decision algorithm | + +The class-level executable unit contract includes a test-only approved pairing to prove its positive branch without inserting production approval data. The public payload-authority boundary separately proves that an exact runtime digest passes to the class-level decision while a substituted or malformed digest is rejected first. The executor contract proves that the runtime digest is derived from the exact admitted factor-score/design vector and draw matrix rather than an ad hoc consumer serialization. Test fixtures are private and cannot populate the production registry. + +"Eligible" is narrower than release-ready. ADR 0014 still requires implementation authority, claim-specific scientific evidence, exact-head quality/security evidence, and qualifying review before a protected-main product claim. + +## Current production registry + +There is intentionally no approved production Rubin generator/analysis pairing on this branch. Draft #504 is candidate repeated-sampling evidence for its declared synthetic Gaussian generator and rolling-origin design. Its branch-local results must not be inserted into a production allow-list while its exact head lacks terminal required checks and qualifying independent current-head approval. + +Any future production pairing must bind the authoritative Validation Evidence availability and the immutable evidence source snapshot identity **and canonical source snapshot SHA-256** alongside the immutable evidence ID/digest. A caller-provided receipt cannot introduce, override, backdate, retarget, or re-content any owner-controlled provenance field. The registry also cannot substitute another analysis ID/version for the canonical Rubin loading analysis contract baked into the receipt type. Separately, each concrete activation receipt must bind the exact executor-produced estimator payload used by that run. + +## Design envelope for the current candidate evidence + +The #504 evidence is scoped to the current single-level `rubin_loading_uncertainty_v1` profile for its declared Gaussian simulation design, tested observation/draw settings, explicit attempted/recovered/failed denominators, bias/RMSE and Monte Carlo uncertainty, a scoped large-sample normal coverage diagnostic, and leakage-safe rolling-origin replay. It does not establish universal congeniality, multilevel/cross-classified/multiple-membership validity, arbitrary imputation-model validity, or Mislevy person-level plausible-value inference. + +A future evidence package can widen the envelope only by adding relevant known-truth recovery/coverage evidence and receiving a new immutable identity/digest. Updating a mutable PR body or replacing an evidence file under the same identity is not admissible widening. Exact observation/draw count points are part of the evidence envelope; an envelope label does not authorize untested intermediate or extrapolated dimensions. + +## Leakage, replay, and payload-substitution invariants + +For a fixed immutable snapshot and cutoff, later-available source rows cannot change the historical result. The same rule applies to activation authority: Validation Evidence unavailable at the historical cutoff cannot retroactively authorize the older run. A later evidence package may authorize a later run under a later cutoff, but the earlier artifact remains descriptive-only unless a new artifact is produced under the corresponding authority and product policy. + +The availability clock is owner-controlled approval metadata, not a caller assertion. Matching an approved Validation Evidence ID and digest while supplying an earlier receipt timestamp is insufficient and fails closed. Equivalent RFC 3339 spellings of the same instant compare equal only after typed parsing; persisted receipt and registry timestamps remain canonical. Cross-snapshot evidence is a provenance violation, not a censorable future row. + +Snapshot provenance is owner-controlled authority, not presentation text. Mutable refs such as `main`, `refs/heads/main`, PR numbers, repository tree URLs, or latest aliases cannot identify the source population of an authoritative projection even if they currently resolve to the intended commit. A stable logical snapshot ID is still insufficient by itself: historical replay must also bind the exact source bytes with a canonical SHA-256. The receipt/runtime identity and digest must match the identity and digest recorded by the approved Validation Evidence pairing. + +Concrete estimator content is a third content boundary. The same snapshot and the same matrix dimensions can carry different factor scores or draw values. The executor hashes only the cutoff-eligible, snapshot-admitted factor-score vector and draw-major matrix that it actually passes to `psychometric_core`; late rows do not enter that hash. The versioned domain separator, admitted dimensions, fixed byte order, and exact IEEE-754 bits make the representation deterministic. An activation receipt issued for one such payload cannot be replayed against another payload with the same observation count and draw count. `estimator_payload_sha256` participates in both the Rubin artifact digest and activation receipt SHA-256 and is independently rechecked against runtime input before approval. This prevents numeric-input substitution; it does not certify how the matching bytes were generated or mapped. + +## Evidence identity and mutability + +A Validation Evidence digest is content identity, not a review badge. The receipt binds the evidence artifact containing the scientific design, attempted/recovered/failed population, recovery/coverage summaries, Monte Carlo uncertainty, implementation/source identity, and applicable design envelope. Git branch names, PR numbers, check URLs, comments, or latest-release aliases are mutable locators and cannot substitute for the digest-bound evidence identity. + +Content identity, availability provenance, source-population identity, source-population content identity, and concrete estimator-payload identity are separate invariants. The approved pairing therefore binds the exact evidence digest, authoritative availability instant, immutable source snapshot ID, and canonical source snapshot SHA-256; the per-run executor/artifact/receipt chain separately binds the exact admitted estimator payload. Reusing an evidence digest while changing only a caller-supplied timestamp, snapshot name, snapshot content, factor scores, or draw content cannot change historical eligibility. + +If an approved evidence artifact or source snapshot is superseded, the replacement receives a new identity/digest as applicable. Existing historical receipts continue to name the evidence, source bytes, and estimator-input bytes under which they were evaluated; they are not silently rewritten to the newest package. + +## Remaining implementation sequence + +1. Keep #506's negative projection policy, bounded activation receipt, canonical Rubin analysis identity, immutable snapshot identity/digest validation, exact runtime design-point validation, public-wire refusal, owner-controlled evidence-availability matching, owner-controlled evidence snapshot identity/digest matching, #515 payload binding, #516 executor ownership, and #522 factor-score design commitment intact. +2. Keep the production approved-pairing registry empty while #504 is Draft or lacks exact-head scientific/review gates. +3. Resolve trusted generator/mapping artifact issuance if positive activation requires proof that the approved generator and mapping implementation actually produced the executor-hashed bytes. A matching content hash must not be silently promoted into execution attestation. +4. Once candidate evidence is independently accepted, publish its immutable Validation Evidence identity/digest, authoritative `AvailableTime`, immutable source snapshot ID, and canonical source snapshot SHA-256, then add only that exact pairing through the owner path. +5. Bind any future positive artifact projection state to the full activation-receipt digest and reacquire exact-head tests, authored line/branch coverage, documentation, security, CodeQL, and independent review. +6. Fold the complete source/test/evidence delta into the surviving Analysis Run vehicle by ordinary non-force conflict resolution; predecessor checks and approvals do not transfer. + +## Primary evidence + +- Meng, X.-L. (1994). Multiple-imputation inferences with uncongenial sources of input. *Statistical Science, 9*(4), 538–558. https://doi.org/10.1214/ss/1177010269 +- Rubin, D. B. (1987). *Multiple Imputation for Nonresponse in Surveys*. Wiley. https://doi.org/10.1002/9780470316696 +- Rubin, D. B. (1996). Multiple imputation after 18+ years. *Journal of the American Statistical Association, 91*(434), 473–489. https://doi.org/10.1080/01621459.1996.10476908 +- Xie, X., & Meng, X.-L. (2017). Dissecting multiple imputation from a multi-phase inference perspective: What happens when God's, imputer's and analyst's models are uncongenial? *Statistica Sinica, 27*(4), 1485–1594. https://doi.org/10.5705/ss.2014.067 \ No newline at end of file