From 4ab92f3f2dad72ca8685bac6afc68d9ae813d626 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 00:12:27 +0000 Subject: [PATCH 01/14] feat(analysis): bind posterior topic-context producer to an analysis-run profile GAP-004 leftover / ADR 0068. Bind existing TopicContextPosteriorArtifact to cutoff-safe topic_context_posterior_v1. Posterior coordinates are not importance; missing draws are not collapsed; lineage events stay producer-supplied. --- CHANGELOG.md | 2 + crates/analysis_engine/src/lib.rs | 10 +- .../src/topic_context_posterior.rs | 86 +++++- ...ic_context_posterior_execution_contract.rs | 244 ++++++++++++++++++ docs/TRACEABILITY.md | 1 + ...68-topic-context-posterior-analysis-run.md | 86 ++++++ docs/adr/README.md | 2 + .../topic-context-posterior-analysis-run.md | 16 ++ 8 files changed, 442 insertions(+), 5 deletions(-) create mode 100644 crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs create mode 100644 docs/adr/0068-topic-context-posterior-analysis-run.md create mode 100644 docs/doctoring/topic-context-posterior-analysis-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 062a69412..97ba332e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] +- **Posterior topic-context analysis-run profile**: cutoff-safe `topic_context_posterior_v1` binds `TopicContextPosteriorArtifact` and refuses importance, collapsed draws, and invented birth/split/merge (`analysis_engine`). Not a Bayesian sampler and not implemented-main. + - `event_core` adds bounded Allen interval-consistency classification, atomic path-consistency closure, contradiction/resource refusals, and an explicit dependency-error fallback without claiming unrestricted global satisfiability. - `psychometric_core` recovers the Driver, Oud, and Voelkle (2017, Table 2, p. 12 `MANIFESTTRAITVAR`; §7.1, p. 19; p. 16 `MANIFESTTRAITVARstd`; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-27T14:20Z from https://www.jstatsoft.org/index.php/jss/article/download/v077i05/1104) scalar standardised manifest-trait variance on current main after `0ce16e8` dropped the pre-consolidation code while research notes already named the map (register items 83–84). Table 2 names `MANIFESTTRAITVAR` `Ψ_τ` the additional time-invariant variance-covariance on the measurement level and sets it `NULL` when there is no manifest trait. Equation 5 writes `Γ ~ N(τ, Ψ)` and names that covariance the manifest traits. Section 7.1 names manifest traits stable individual differences in indicator levels, distinct from process-level `TRAITVAR` `φ_ξ`. Page 16 prints standardised matrices with the suffix `std` when appropriate. The printed example on p. 16 is `discreteDRIFTstd`, not `MANIFESTTRAITVARstd`. Footnote 4 standardises using only the relevant variance, not the total. The relevant variance for that named indicator-level correlation is `MANIFESTTRAITVAR`, not process-level `TRAITVAR` and not residual `MANIFESTVAR` `θ`. The 2017-era source forms `MANIFESTTRAITVARstd` only when `MANIFESTTRAITVAR != 0`, as `solve(sqrt(diag(MANIFESTTRAITVAR) + ridging)) %&% MANIFESTTRAITVAR` when `verbose = TRUE`. OpenMx `%&%` is `t(A) %*% B %*% A`. Unlike `TRAITVARstd`, that formation adds `diag(c(ridging), n.manifest)`. The default `ridging = FALSE` adds 0, not `0.0001`; that ridge is a numerical hack and is not this exact map. The scalar correlation is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR`. Form strictly positive `ψ` first, then `1 / √ψ`, then `(1 / √ψ) ψ (1 / √ψ)`. Unstandardised `MANIFESTTRAITVAR` is defined for a zero trait; standardised `MANIFESTTRAITVAR` is not. Zero `MANIFESTTRAITVAR` skips forming `MANIFESTTRAITVARstd` in the 2017-era source and fails closed here. Indicator-level trait variance is an event-time structural quantity, so a non-event clock fails closed. `MANIFESTTRAITVAR` does not require stable `a < 0`. Distinct positive `ψ` recover the same 1. `trait / trait = 1` is `TRAITVARstd` and recovers the same number and remains a distinct named quantity. `θ` is `MANIFESTVAR` and is measurement error, not this correlation. Meredith (1993) remains unread (web search 2026-08-27T14:20Z: Springer/Cambridge Core paywalled; Unpaywall historically `is_oa: false`; Springer `content/pdf` is an HTML stub). Mislevy (1991, *Psychometrika, 56*, 177–196) remains unread on the same terms (DOI `10.1007/bf02294457`). Still not a Kalman filter, not a matrix `expm`, not ESEM estimation, not DSEM, and not ctsem estimation. diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 72bd5854c..960fa9d3b 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -8,7 +8,9 @@ //! through [`tepp_api`]. It deliberately does not claim latent-variable or topic //! estimation authority; those estimators remain separate scientific crates. //! estimation authority; it invokes estimators through their scientific crate -//! contracts and preserves their artifact meaning. +//! contracts and preserves their artifact meaning. Posterior topic-context +//! artifacts are validated through [`TopicContextPosteriorArtifact`] and do +//! not claim topic importance. mod case_deletion_refit; mod lineage_criterion; @@ -48,9 +50,11 @@ pub use lineage_criterion::{ }; /// Bounded posterior topic-context producer contract and record types. pub use topic_context_posterior::{ - TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, + TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION, + TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, TopicActivityInterval, TopicContextMembership, TopicContextPosteriorArtifact, - TopicDocumentRelation, TopicLineageEvent, TopicPostPlausibleValue, + TopicContextPosteriorExecution, TopicDocumentRelation, TopicLineageEvent, + TopicPostPlausibleValue, execute_topic_context_posterior_run, }; /// Topic-lineage artifact and execution contracts from this engine. pub use topic_lineage_artifact::{ diff --git a/crates/analysis_engine/src/topic_context_posterior.rs b/crates/analysis_engine/src/topic_context_posterior.rs index e625305c5..e5da785db 100644 --- a/crates/analysis_engine/src/topic_context_posterior.rs +++ b/crates/analysis_engine/src/topic_context_posterior.rs @@ -7,12 +7,21 @@ use sha2::{Digest, Sha256}; use temporal_core::KnowledgeCutoff; use uuid::Uuid; -use crate::{AnalysisEngineError, format_digest, valid_identifier}; +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{AnalysisEngineError, format_digest, require_receipt_identity, valid_identifier}; /// Exact posterior artifact schema. pub const TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION: &str = "tepp.topic_context_posterior.v1"; /// Maximum canonical JSON size. pub const TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT: usize = 16 * 1024 * 1024; +/// Model contract required by the topic-context posterior analysis-run path. +pub const TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION: &str = "topic_context_posterior_v1"; +/// Analysis-run output profile required for a topic-context posterior artifact. +pub const TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE: &str = "topic_context_posterior_v1"; +const TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS: &str = "posterior_topic_coordinates_not_importance"; const ENTRY_LIMIT: usize = 1_000_000; const DIMENSIONS: [&str; 4] = ["business_unit", "process_unit", "team", "person"]; type PosteriorDraws = BTreeMap>; @@ -217,7 +226,7 @@ impl TopicContextPosteriorArtifact { ], entry_limit, ) - && self.inference_status == "posterior_topic_coordinates_not_importance" + && self.inference_status == TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS } /// Parse and validate one bounded posterior artifact. @@ -636,6 +645,79 @@ impl TopicContextPosteriorArtifact { } } +/// One completed topic-context posterior artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct TopicContextPosteriorExecution { + /// Digest-bound producer posterior artifact. + pub artifact: TopicContextPosteriorArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute posterior topic-context validation as one analysis-run profile. +/// +/// The executor validates an already-constructed +/// [`TopicContextPosteriorArtifact`] through its producer contract and does +/// not reimplement TRSL-TM fitting, collapse missing draws, infer topic +/// importance, or invent birth/split/merge events. Lineage events remain +/// producer-supplied. This is not a Bayesian sampler and not GPU execution. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error or a producer +/// contract refusal. +pub fn execute_topic_context_posterior_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + snapshot_id: &str, + knowledge_cutoff: KnowledgeCutoff, + artifact: &TopicContextPosteriorArtifact, + completed_at: impl Into, +) -> Result { + request.to_json()?; + accepted.to_json()?; + require_receipt_identity(request, accepted)?; + if request.snapshot_id != snapshot_id || artifact.snapshot_id != snapshot_id { + return Err(AnalysisEngineError::SnapshotMismatch); + } + if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + || artifact.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + || request.model_contract_version != TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION + || request.output_profile != TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE + || artifact.run_id != accepted.run_id + || artifact.inference_status != TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let digest = artifact.sha256()?; + let mut document_ids = BTreeSet::new(); + for value in &artifact.plausible_values { + document_ids.insert(value.document_id.as_str()); + } + let document_count = + u64::try_from(document_ids.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; + let summary = AnalysisResultSummary::new( + "topic_context_posterior", + document_count, + 3, + TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS, + )?; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("topic_context_posterior_artifact_{}", &digest[..16]), + digest, + TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(TopicContextPosteriorExecution { + artifact: artifact.clone(), + terminal_result, + }) +} + #[cfg(test)] mod tests { use super::AnalysisEngineError; diff --git a/crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs b/crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs new file mode 100644 index 000000000..d7ce40bf2 --- /dev/null +++ b/crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs @@ -0,0 +1,244 @@ +//! End-to-end contract for cutoff-safe posterior topic-context analysis-run. + +use analysis_engine::{ + AnalysisEngineError, TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION, + TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, + TopicActivityInterval, TopicContextMembership, TopicContextPosteriorArtifact, + TopicDocumentRelation, TopicPostPlausibleValue, execute_topic_context_posterior_run, +}; +use temporal_core::KnowledgeCutoff; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; + +fn cutoff() -> KnowledgeCutoff { + KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") +} + +fn artifact() -> TopicContextPosteriorArtifact { + let documents = [ + "018f3f7a-7b7c-7d00-8000-000000000001", + "018f3f7a-7b7c-7d00-8000-000000000002", + ]; + TopicContextPosteriorArtifact { + schema_version: TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION.into(), + run_id: "run-topic-context-posterior".into(), + snapshot_id: "snapshot-topic-context-posterior".into(), + source_snapshot_sha256: "0".repeat(64), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + event_clock_code: "event_time_rfc3339".into(), + model_contract_version: "trsl-tm-v1".into(), + posterior_draw_set_id: "draw-set-1".into(), + posterior_draw_count: 2, + topic_count: 2, + topic_ids: vec![ + "018f3f7a-7b7c-7d00-8000-000000000101".into(), + "018f3f7a-7b7c-7d00-8000-000000000102".into(), + ], + activity_intervals: [ + "018f3f7a-7b7c-7d00-8000-000000000101", + "018f3f7a-7b7c-7d00-8000-000000000102", + ] + .map(|topic_id| TopicActivityInterval { + topic_id: topic_id.into(), + state_code: "active".into(), + valid_from: "2026-07-01T00:00:00Z".into(), + valid_to: "2026-07-15T00:00:00Z".into(), + }) + .into(), + lineage_events: vec![], + document_relations: vec![TopicDocumentRelation { + source_document_id: documents[0].into(), + target_document_id: documents[1].into(), + relation_kind_code: "event_lineage_precedes".into(), + event_time: "2026-07-15T00:00:00Z".into(), + evidence_sha256: "c".repeat(64), + evidence_resource_id: "evidence-relation-1".into(), + provenance_assertion_id: "provenance-relation-1".into(), + }], + plausible_values: documents + .iter() + .flat_map(|document| { + (0..2).map(|draw| TopicPostPlausibleValue { + document_id: (*document).into(), + draw_index: draw, + event_time: "2026-07-15T00:00:00Z".into(), + logistic_normal_coordinates: vec![if draw == 0 { 0.0 } else { 0.1 }], + }) + }) + .collect(), + memberships: documents + .iter() + .flat_map(|document| { + ["business_unit", "process_unit", "team", "person"].map(|dimension| { + TopicContextMembership { + document_id: (*document).into(), + dimension_code: dimension.into(), + context_id: format!("{dimension}-{document}"), + weight: 1.0, + valid_from: "2026-07-01T00:00:00Z".into(), + valid_to: "2026-08-01T00:00:00Z".into(), + evidence_sha256: "b".repeat(64), + evidence_resource_id: format!("evidence-{dimension}-{document}"), + provenance_assertion_id: format!("provenance-{dimension}-{document}"), + } + }) + }) + .collect(), + inference_status: "posterior_topic_coordinates_not_importance".into(), + } +} + +fn request() -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "topic-context-posterior-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-topic-context-posterior".into(), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + model_contract_version: TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION.into(), + output_profile: TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE.into(), + } +} + +fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { + AnalysisRunAccepted::new( + "run-topic-context-posterior", + "accepted", + &request.idempotency_key, + ) + .expect("accepted") +} + +fn execute( + request: &AnalysisRunRequest, +) -> Result { + execute_topic_context_posterior_run( + request, + &accepted(request), + "snapshot-topic-context-posterior", + cutoff(), + &artifact(), + "2026-08-02T00:00:00Z", + ) +} + +#[test] +fn validated_posterior_emits_digest_without_claiming_importance() { + let request = request(); + let execution = execute(&request).expect("execution"); + assert_eq!( + execution.artifact.schema_version, + TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION + ); + assert_eq!(execution.artifact.topic_count, 2); + assert_eq!(execution.artifact.posterior_draw_count, 2); + assert_eq!( + execution.artifact.inference_status, + "posterior_topic_coordinates_not_importance" + ); + assert_eq!( + execution.terminal_result.run_state, + AnalysisRunTerminalState::Succeeded + ); + assert_eq!( + execution.terminal_result.result_sha256.as_deref(), + Some(execution.artifact.sha256().expect("digest").as_str()) + ); + assert_eq!( + execution.terminal_result.result_schema_version.as_deref(), + Some(TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION) + ); +} + +#[test] +fn producer_contract_refusal_and_run_identity_mismatch_fail_closed() { + let request = request(); + let mut invalid = artifact(); + invalid.plausible_values.pop(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + "snapshot-topic-context-posterior", + cutoff(), + &invalid, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + let mut mismatched_run = artifact(); + mismatched_run.run_id = "other-run".into(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + "snapshot-topic-context-posterior", + cutoff(), + &mismatched_run, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { + let request = request(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + "other-snapshot", + cutoff(), + &artifact(), + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); + for invalid_request in [ + { + let mut value = request.clone(); + value.knowledge_cutoff = "2026-08-02T00:00:00Z".into(); + value + }, + { + let mut value = request.clone(); + value.model_contract_version = "other-model".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "lineage_criterion_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "case_deletion_refit_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "composed_fitted_lineage_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "fitted_candidate_k_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "trsl_topic_lineage_v1".into(); + value + }, + { + let mut value = request.clone(); + value.output_profile = "method_effects_v1".into(); + value + }, + ] { + assert_eq!( + execute(&invalid_request), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} diff --git a/docs/TRACEABILITY.md b/docs/TRACEABILITY.md index 2b783c2ab..cc9ae717b 100644 --- a/docs/TRACEABILITY.md +++ b/docs/TRACEABILITY.md @@ -74,6 +74,7 @@ The full APA 7th standards/literature register remains `docs/research/standards- | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `modality_source` modality-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `corpus_background` background-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | | report template/section/copied/style/modality method effects | ADR 0004/0012; PRD/TRD | simulation truth factors implemented; `prompt_source` prompt-versus-unique-content identity on the active PR; estimator-side method model remains future | partial | +| posterior topic-context analysis-run | ADR 0022/0024/0068 | `analysis_engine` `topic_context_posterior_v1` binds `TopicContextPosteriorArtifact`; posterior coordinates not importance; refuses collapsed draws; lineage events remain producer-supplied; not a Bayesian sampler and not implemented-main | active-PR | | candidate K statistical/Pareto gates | ADR 0012; research | `model_selection` fits each candidate `K` with the CPU `f64` reference and scores the actual mixture likelihood plus Schwarz's (1978) `ℓ − (p ln N)/2` penalty before the Pareto gate; candidate blinding, blinded LLM review, GPU, and backend comparison remain accepted-target | active-PR | | compositional topic correlation / stable clustering | ADR 0005/0012; research | future `network_analysis` | accepted-target | | posterior ESEM / longitudinal invariance / DSEM | ADR 0005 | `psychometric_core` construct/input gates, true-loading OLS recovery, posterior-draw point-estimate averaging, Rubin `T` on draw-level OLS loadings, CWC within/between OLS plus the contextual effect, event-time log-rate, constant- and time-varying-predictor discrete effects (Voelkle Eqs. 12 and 14), exact scalar discrete process noise (Driver et al., 2017, Eq. 3), lagged latent covariance and unconditional latent variance (Driver et al., 2017, Eq. 3–4), stationary within-subject variance (Driver et al., 2017, Eq. 4 as `Δt → ∞`; `asymDIFFUSION`), trait-plus-state variance (Driver et al., 2017, §4.3 `TRAITVAR`; not process noise), observed-indicator variance and lagged observed covariance (Driver et al., 2017, Eq. 5; Table 2 `MANIFESTVAR` is `Θ`, not `Var(y)`; `MANIFESTTRAITVAR` is not `MANIFESTVAR`; `Θ` does not enter lagged observed covariance; observed-indicator mean is `τ + λ μ`; `MANIFESTMEANS` is not `E(y)`; `CINT` is not `MANIFESTMEANS`; discrete latent mean is `exp(a Δt) μ_0 + (exp(a Δt) − 1)/a κ`; `T0MEANS` is not `μ_t`; evolved observed mean is `τ + λ μ_t`; `τ + λ μ_0` is not `E(y_t)`; contemporaneous `TDPREDEFFECT` impulse is `m x`, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that contemporaneous impulse is `τ + λ(μ_t + m x)`, and `τ + λ μ_t` is not that observed mean; time-independent `TIPREDEFFECT` increment is `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, not `M x`, not Voelkle Eq. 14, and not the coefficient `B`; Eq. 5 of that increment is `τ + λ(μ_t + A^{-1}[e^{A Δt} − I] B z)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that observed mean; `τ + λ(μ_t + e^{a(t−u)} m x)` is not that observed mean when `u ≠ t`; within-interval `TDPREDEFFECT` carry is `e^{A(t−u)} M x` for `t0 < u < t`, not the contemporaneous Dirac, not `CINT`, not `TIPREDEFFECT`, and not Voelkle Eq. 14; Eq. 5 of that carry is `τ + λ(μ_t + e^{a(t−u)} m x)`, and `τ + λ μ_t` is not that observed mean; `τ + λ(μ_t + m x)` is not that carried observed mean when `u ≠ t`; §7.2 level-change `CINT` is `κ = −a m x` (`a < 0`; not the dissipating Dirac, not a free `CINT`, not `TIPREDEFFECT`; Eq. 3 of that setting is `(1 − e^{a Δt}) m x`); §7.2 extra-process contribution is `a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)` (not `κ = −a m x`, not `(1 − e^{a Δt}) m x`, not the dissipating Dirac; `ε ≥ 0` fails closed; Eq. 5 of that contribution is `τ + λ(μ_t + a_{ηξ} x (e^{ε Δt} − e^{a Δt}) / (ε − a)`; extra `LAMBDA` is 0; `τ + λ μ_t` is not that observed mean; after-t0 extra-process `TDPREDEFFECT` uses `t − u` with `t0 < u < t` while `μ_t` uses `Δt`; that after-t0 observed mean is not the first-occasion extra-process observed mean; §7.2 `asymTIPREDEFFECT` is `-B z / a` for `a < 0` and is not `B`, not `A^{-1}[e^{A Δt} − I] B z`, not `CINT`, and not `M x`; §7.2 `addedTIPREDVAR` is `(B / a)² v` and is not `TRAITVAR`, not `asymDIFFUSION`, and not `-B z / a`; Table 2 `asymCINT` is `-κ / a` for `a < 0` and is not `κ`, not `A^{-1}[e^{A Δt} − I] κ`, not `T0MEANS`, and not `-B z / a`; p. 16 stationary `T0MEANS` is `-κ / a + −B z / a` and is not free `T0MEANS`, not `asymCINT` alone, not `asymTIPREDEFFECT` alone, and not the finite-interval discrete latent mean; Eq. 5 of that constrained mean is `τ + λ(−κ / a + −B z / a)`; `τ + λ μ_0` is not that observed mean; `MANIFESTMEANS` is not `E(y_0)`; the constrained latent mean is not `E(y_0)`; stationary `T0VAR` is `trait + −q / (2 a) + (B / a)² v` (not free `T0VAR`, not `asymDIFFUSION` alone, not `TRAITVAR` alone, not `addedTIPREDVAR` alone, and not the finite-interval discrete latent variance. Eq. 5 of that constrained variance is `λ²(trait + −q / (2 a) + (B / a)² v) + θ + ψ` (JSS PDF re-opened 2026-08-22T03:20Z; form the stationary latent variance first, then `λ² p + θ + ψ`; `λ² p_0` is not that observed variance; `λ²(−q / (2 a)) + θ` is not that observed variance when `TRAITVAR` or `addedTIPREDVAR` is nonzero; `MANIFESTVAR` is not `Var(y_0)`; the constrained latent variance is not `Var(y_0)`)); lagged stationary `T0VAR` is `trait + e^{a Δt}(−q / (2 a)) + (B / a)² v` (trait and `addedTIPREDVAR` do not decay; contemporaneous `T0VAR` is not that lagged map; decaying the constrained total as if it were all state is not that lagged map; Eq. 5 of that lagged covariance is `λ²(trait + e^{a Δt}(−q / (2 a)) + (B / a)² v) + ψ`; `Θ` does not enter; contemporaneous `Var(y_0)` is not that lagged observed covariance; the lagged latent covariance is not that observed covariance); later-occasion stationary `T0VAR` is `trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v` (trait and `addedTIPREDVAR` do not enter `Q_Δt`; under stationarity that composition equals contemporaneous `T0VAR`; evolving the constrained total as if it were all state is not that later map; the lagged covariance omits `Q_Δt`; `Q_Δt` is not that later map; Eq. 5 of that later-occasion variance is `λ²(trait + e^{2 a Δt}(−q / (2 a)) + Q_Δt + (B / a)² v) + θ + ψ`; lagged observed covariance omits `Q_Δt` and `θ`; `MANIFESTVAR` is not `Var(y_t)`; the later-occasion latent variance is not `Var(y_t)`); predetermined later-occasion `T0VAR` is `trait + e^{2 a Δt} p_0 + Q_Δt + (B / a)² v` (free `T0VAR` `p_0` is not that later map; setting `p_0 = −q / (2 a)` recovers the stationary later-occasion map; stationary later variance uses `−q / (2 a)` in place of `p_0` and is not that later map when `p_0` is free; evolving `trait + p_0 + (B / a)² v` as if it were all state is not that later map; Eq. 5 of that predetermined later-occasion variance is `λ²(trait + e^{2 a Δt} p_0 + Q_Δt + (B / a)² v) + θ + ψ`; `MANIFESTVAR` is not `Var(y_t)`; the predetermined later-occasion latent variance is not `Var(y_t)`; stationary later observed variance is not that observed variance when `p_0` is free); predetermined lagged `T0VAR` is `trait + e^{a Δt} p_0 + (B / a)² v` (free `T0VAR` `p_0` is not that lagged map; setting `p_0 = −q / (2 a)` recovers the stationary lagged map; stationary lagged covariance uses `−q / (2 a)` in place of `p_0` and is not that lagged map when `p_0` is free; evolving `trait + p_0 + (B / a)² v` as if it were all state is not that lagged map; later-occasion variance includes `Q_Δt` and is not that lagged map; Eq. 5 of that predetermined lagged covariance is `λ²(trait + e^{a Δt} p_0 + (B / a)² v) + ψ`; `MANIFESTVAR` does not enter; the predetermined lagged latent covariance is not that observed covariance; predetermined later observed variance includes `Q_Δt` and `θ` and is not that lagged observed covariance; stationary lagged observed covariance is not that observed covariance when `p_0` is free; the predetermined first-occasion variance of §4.3 predetermined `T0VAR` is `trait + p_0 + (B / a)² v`; free `p_0` is not that map; stationary first-occasion variance uses `−q / (2 a)` in place of `p_0` and is not that map when `p_0` is free; lagged covariance decays the state and is not that map; later-occasion variance includes `Q_Δt` and is not that map; Eq. 5 of that predetermined first-occasion variance is `λ²(trait + p_0 + (B / a)² v) + θ + ψ`; `MANIFESTVAR` is not that first-occasion observed variance; the predetermined first-occasion latent variance is not that observed variance; stationary first-occasion observed variance is not that observed variance when `p_0` is free; predetermined later observed variance includes `Q_Δt` and is not that first-occasion observed variance; later-start lagged covariance of predetermined `T0VAR` is `trait + e^{a s}(e^{2 a u} p_0 + Q_u) + (B / a)² v` (Driver et al., 2017, §4.3 `startoffset`; Eq. 4; JSS PDF re-opened 2026-08-23T10:27Z; first-occasion lagged omits `e^{a s} Q_u`; later-occasion variance does not lag; stationary lagged uses `−q / (2 a)`; decaying the later total is not that map; Eq. 5 of that later-start lagged covariance is `λ²` of it plus `ψ`; `Θ` does not enter; first-occasion lagged observed omits `e^{a s} Q_u`; later observed variance includes `Q_u` and `θ`; later-start later-occasion variance of predetermined `T0VAR` is `trait + e^{2 a s}(e^{2 a u} p_0 + Q_u) + Q_s + (B / a)² v` (Driver et al., 2017, §4.3 `startoffset`; Eq. 3–4 Chapman–Kolmogorov `Q_{u+s} = e^{2 a s} Q_u + Q_s`; JSS PDF re-opened 2026-08-23T11:05Z; later-occasion variance at `u` omits `Q_s`; later-start lagged covariance omits `Q_s`; stationary later uses `−q / (2 a)`; evolving the later total as if it were all state is not that map; ignoring `startoffset` omits `e^{2 a s} Q_u`; Eq. 5 of that later-start later-occasion variance is `λ²` of it plus `θ + ψ`; `MANIFESTVAR` is not that observed variance; p. 16 `discreteDRIFTstd` is `e^{a Δt}` after strictly positive `asymDIFFUSION` `-q / (2 a)` (footnote 4; unstandardised `e^{a Δt}` is defined for growing `a ≥ 0` and for zero diffusion and is not `discreteDRIFTstd`; the §7.1 trait-plus-state autocorrelation uses `TRAITVAR` and is not `discreteDRIFTstd`; p. 16 `discreteDIFFUSIONstd` is `Q_Δt / (−q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` (footnote 4; unstandardised `Q_Δt` is defined for growing `a ≥ 0` and for zero diffusion and is not `discreteDIFFUSIONstd`; the continuous standardisation `−2 a` is not `discreteDIFFUSIONstd`; `Q_Δt / (trait + p + added)` uses `TRAITVAR` and is not `discreteDIFFUSIONstd`; `TRAITVAR` is not the standardisation variance; p. 16 `DIFFUSIONstd` is `q / (−q / (2 a)) = −2 a` after strictly positive `asymDIFFUSION` `-q / (2 a)` (Driver et al., 2017, p. 16; Eq. 4; footnote 4; JSS PDF re-opened 2026-08-23T13:20Z; unstandardised `q` is defined for growing `a ≥ 0` and for zero diffusion and is not `DIFFUSIONstd`; the discrete standardisation `Q_Δt / (−q / (2 a))` depends on `Δt` and is not `DIFFUSIONstd`; `q / (trait + p + added)` uses `TRAITVAR` and is not `DIFFUSIONstd`; `TRAITVAR` is not the standardisation variance; p. 16 `DRIFTstd` is the continuous auto-effect after strictly positive `asymDIFFUSION` `-q / (2 a)` (Driver et al., 2017, p. 16; Eq. 1; footnote 4; JSS PDF re-opened 2026-08-23T13:28Z); unstandardised `a` is defined for growing `a ≥ 0` and for zero diffusion and is not `DRIFTstd`; the discrete standardisation `e^{a Δt}` depends on the event interval and is not `DRIFTstd`; `a p / (trait + p + added)` uses `TRAITVAR` and is not `DRIFTstd`; `TRAITVAR` is not the standardisation variance); p. 16 `asymTIPREDEFFECTstd` is `(-B / a) · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` and strictly positive predictor variance `v` (Driver et al., 2017, p. 16; §7.2; footnote 4; JSS PDF re-opened 2026-08-23T14:25Z; unstandardised `-B / a` is defined for a zero coefficient and for zero predictor variance and is not `asymTIPREDEFFECTstd`; the finite-interval standardisation `A^{-1}[e^{A Δt} − I] B · √v / √p` depends on the event interval and is not `asymTIPREDEFFECTstd`; `(-B / a) · √v / √(trait + p + added)` uses `TRAITVAR` and is not `asymTIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); p. 16 `TIPREDEFFECTstd` is `B · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` `-q / (2 a)` and strictly positive predictor variance `v` (Driver et al., 2017, p. 16; §7.2; footnote 4; JSS PDF re-opened 2026-08-23T16:21Z; unstandardised `B` is defined for a zero coefficient and for zero predictor variance and is not `TIPREDEFFECTstd`; the asymptotic standardisation `(-B / a) · √v / √p` is the total change and is not `TIPREDEFFECTstd`; the finite-interval standardisation `A^{-1}[e^{A Δt} − I] B · √v / √p` depends on the event interval and is not `TIPREDEFFECTstd`; `B · √v / √(trait + p + added)` uses `TRAITVAR` and is not `TIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); Table 3 `T0TIPREDEFFECTstd` is `t0_b · √v / √p_0` after strictly positive free `T0VAR` `p_0` and strictly positive predictor variance `v` (Driver et al., 2017, Table 3, p. 13; p. 16; footnote 4; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T17:20Z; the affected variance is free `T0VAR`, not `asymDIFFUSION`; unstandardised `t0_b` is defined for a zero coefficient and for zero predictor variance and is not `T0TIPREDEFFECTstd`; `TIPREDEFFECTstd` `B · √v / √(-q / (2 a))` is the continuous coefficient and is not `T0TIPREDEFFECTstd`; `asymTIPREDEFFECTstd` `(-B / a) · √v / √p` is the total change and is not `T0TIPREDEFFECTstd`; `t0_b · √v / √(trait + p_0 + added)` uses `TRAITVAR` and is not `T0TIPREDEFFECTstd`; `TRAITVAR` is not the standardisation variance); 2017-era `addedT0TIPREDVAR` is `t0_b² v` (Driver et al., 2017, Table 3, p. 13; p. 16; §7.2; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T18:20Z; `T0TIPREDEFFECT %*% TIPREDVAR %*% t(T0TIPREDEFFECT)` immediately after `T0TIPREDEFFECTstd`; form `t0_b` first, then square, then multiply by `v`; a zero coefficient or zero predictor variance is exactly zero; free `T0TIPREDEFFECT` does not require `a < 0`; `(B / a)² v` is `addedTIPREDVAR` and is not this first-occasion map; `t0_b · √v / √p_0` is `T0TIPREDEFFECTstd` and is not this variance; free `T0VAR` is not this extra TI variance; `TRAITVAR` is not this extra TI variance; Equation 5 of 2017-era `addedT0TIPREDVAR` is `λ² t0_b² v` (Driver et al., 2017, Eq. 5, p. 5; Table 3, p. 13; Table 2, p. 12; 2017-era ctsem `summary.ctsemFit.R`; JSS PDF re-opened 2026-08-23T19:10Z; form `t0_b² v` first, then `(λ extra) λ` with `θ = 0`; a zero loading or zero extra is exactly zero; `t0_b² v` is the latent extra, not the observed extra; `λ² p_0 + θ` is first-occasion observed variance, not this extra; `λ² (B / a)² v` is Eq. 5 of `addedTIPREDVAR`, not this first-occasion observed extra; `MANIFESTVAR` `θ` is not this extra; Equation 5 of §7.2 `addedTIPREDVAR` is `λ² (B / a)² v`; form `(B / a)² v` first, then `(λ extra) λ` with `θ = 0`; a zero loading or zero extra is exactly zero; lasting asymptotic extra requires `a < 0`; `(B / a)² v` is the latent extra, not the observed extra; `λ² t0_b² v` is first-occasion extra observed TI variance, not this extra; `λ² p + θ` is stationary observed variance, not this extra; `MANIFESTVAR` `θ` is not this extra; p. 16 `TDPREDEFFECTstd` is `m · √v / √(-q / (2 a))` after strictly positive `asymDIFFUSION` and strictly positive time-dependent predictor variance; unstandardised `M` is not `TDPREDEFFECTstd`; `TIPREDEFFECTstd` is not `TDPREDEFFECTstd` even when `M = B`; intercept-style `A^{-1}[e^{A Δt} − I] M · √v / √p` is not `TDPREDEFFECTstd`; `m · √v / √(trait + p + added)` uses `TRAITVAR` and is not `TDPREDEFFECTstd`; Table 3 / p. 16 `T0TDPREDEFFECTstd` is `t0_m · √v / √p_0` after strictly positive free `T0VAR` and strictly positive TD predictor variance; unstandardised `t0_m` is not `T0TDPREDEFFECTstd`; `TDPREDEFFECTstd` uses `asymDIFFUSION` and is not `T0TDPREDEFFECTstd`; `T0TIPREDEFFECTstd` is not `T0TDPREDEFFECTstd` even when `t0_m = t0_b`; `t0_m · √v / √(trait + p_0 + added)` uses `TRAITVAR` and is not `T0TDPREDEFFECTstd`; free `T0VAR` does not require `a < 0`; p. 16 `T0VARstd` is `p_0 / p_0 = 1` after strictly positive free `T0VAR` (`solve(sqrt(diag(T0VAR))) %&% T0VAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; default ridge is 0); unstandardised `T0VAR` is not `T0VARstd`; `T0TDPREDEFFECTstd` is not `T0VARstd`; `addedT0TIPREDVAR` is not `T0VARstd`; p. 16 `TRAITVARstd` is `trait / trait = 1` after strictly positive `TRAITVAR` (`solve(sqrt(diag(TRAITVAR))) %&% TRAITVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; no ridge addend); unstandardised `TRAITVAR` is not `TRAITVARstd`; `T0VARstd` is not `TRAITVARstd` even when both equal 1; `addedT0TIPREDVAR` is not `TRAITVARstd`; p. 16 `MANIFESTTRAITVARstd` is `ψ / ψ = 1` after strictly positive `MANIFESTTRAITVAR` (`solve(sqrt(diag(MANIFESTTRAITVAR))) %&% MANIFESTTRAITVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0); unstandardised `MANIFESTTRAITVAR` is not `MANIFESTTRAITVARstd`; `TRAITVARstd` is not `MANIFESTTRAITVARstd` even when both equal 1; `MANIFESTVAR` is not `MANIFESTTRAITVARstd`; p. 16 `MANIFESTVARstd` is `θ / θ = 1` after strictly positive `MANIFESTVAR` (`solve(sqrt(diag(MANIFESTVAR))) %&% MANIFESTVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; 2017-era `dimnames` assignment to `latentNames` is a source bug); unstandardised `MANIFESTVAR` is not `MANIFESTVARstd`; `MANIFESTTRAITVARstd` is not `MANIFESTVARstd` even when both equal 1; Equation 5 `Var(y)` is not `MANIFESTVARstd`; p. 16 `TIPREDVARstd` is `v / v = 1` after strictly positive `TIPREDVAR` (`solve(sqrt(diag(TIPREDVAR))) %&% TIPREDVAR`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; `dimnames` are `TIpredNames`); unstandardised `TIPREDVAR` is not `TIPREDVARstd`; `MANIFESTVARstd` is not `TIPREDVARstd` even when both equal 1; §7.2 `addedTIPREDVAR` is not `TIPREDVARstd`; p. 16 `asymDIFFUSIONstd` is `p / p = 1` after strictly positive `asymDIFFUSION` (`solve(sqrt(diag(asymDIFFUSION))) %&% asymDIFFUSION`; OpenMx `%&%` is `t(A) %*% B %*% A`; 2017-era source adds ridging; default ridge is 0; `dimnames` are `latentNames`); unstandardised `asymDIFFUSION` is not `asymDIFFUSIONstd`; `TIPREDVARstd` is not `asymDIFFUSIONstd` even when both equal 1; `DIFFUSIONstd` `−2 a` is not `asymDIFFUSIONstd`; p. 16 `discreteCINTstd` is `A^{-1}[e^{A Δt} − I] κ / √p` after strictly positive `asymDIFFUSION`; unstandardised `discreteCINT` is not `discreteCINTstd`; `κ / √p` is not `discreteCINTstd`; `(-κ / a) / √p` is not `discreteCINTstd`; `asymCINTstd` is `(-κ / a) / √p` after strictly positive `asymDIFFUSION`; unstandardised `asymCINT` is not `asymCINTstd`; `κ / √p` is not `asymCINTstd`; `discreteCINTstd` is not `asymCINTstd`; `T0MEANSstd` is `μ_0 / √p_0` after strictly positive free `T0VAR`; unstandardised `T0MEANS` is not `T0MEANSstd`; `T0VARstd` is not `T0MEANSstd`; `μ_0 / √asymDIFFUSION` is not `T0MEANSstd`; `MANIFESTMEANSstd` is `τ / √θ` after strictly positive `MANIFESTVAR`; unstandardised `MANIFESTMEANS` is not `MANIFESTMEANSstd`; `MANIFESTVARstd` is not `MANIFESTMEANSstd`; `τ / √(λ² Var(η) + θ)` is not `MANIFESTMEANSstd`; p. 16 `CINTstd` is `κ / √p` after strictly positive `asymDIFFUSION`; unstandardised `CINT` is not `CINTstd`; `asymCINTstd` is not `CINTstd`; `discreteCINTstd` is not `CINTstd`; `κ / √(trait + p + added)` is not `CINTstd`;))))), irregular already-centered residual lag, and strong/strict-gated latent means on the stacked psychometric PR (two-observation residual variance is identically `0` and caps at strong/scalar; Putnick & Bornstein, 2016, PMC5145197 opened 2026-08-19T22:15Z); full ESEM/DSEM remaining | partial | diff --git a/docs/adr/0068-topic-context-posterior-analysis-run.md b/docs/adr/0068-topic-context-posterior-analysis-run.md new file mode 100644 index 000000000..555e032ca --- /dev/null +++ b/docs/adr/0068-topic-context-posterior-analysis-run.md @@ -0,0 +1,86 @@ +# ADR 0068 — Posterior topic-context producer as an analysis-run output profile + +**Decision status:** Accepted +**Implementation maturity:** active-PR — composed on this branch; not implemented-main +**Date:** 2026-09-01 +**Supersedes:** None; complements ADR 0022 (cutoff-safe analysis-run execution) and ADR 0024 (posterior topic-context producer contract). +**Figma File ID:** N/A — this increment changes a Rust service crate and has no user-interface surface. +**Storybook inventory:** N/A — no reusable web object or interaction changed. + +## Context + +Protected main already validates digest-bound posterior topic-context +artifacts inside `analysis_engine::TopicContextPosteriorArtifact`. The +producer contract keeps full-rank logistic-normal coordinates, refuses +collapsed missing draws, and labels the claim boundary +`posterior_topic_coordinates_not_importance`. Operators still cannot +request that validator as a cutoff-safe analysis-run output. + +Independent TDT link-criterion fitting, location-membership refusals, +copied-text residue refusals, provenance-is-not-transition refusals, and +composed fitted-lineage remain different profiles. Full Bayesian sampling, +GPU, and invented topic birth/split/merge remain later GAP-004 work and +are not this slice. ADR 0064 through ADR 0067 are already taken by live +sibling PRs. + +## Decision + +Add the `topic_context_posterior_v1` analysis-run output profile to +`analysis_engine`. The executor: + +- consumes an already-constructed `TopicContextPosteriorArtifact`; +- requires the request snapshot, knowledge cutoff, and accepted run + identity to match the offered artifact; +- invokes the existing producer `sha256`/validate path without + reimplementing TRSL-TM fitting; +- emits a digest-bound terminal result under + `tepp.topic_context_posterior.v1` with inference status + `posterior_topic_coordinates_not_importance`; +- refuses reuse of `lineage_criterion_v1`, `case_deletion_refit_v1`, + `composed_fitted_lineage_v1`, `fitted_candidate_k_v1`, + `trsl_topic_lineage_v1`, and `method_effects_v1` as this profile; +- does not invent a Bayesian sampler, persist rows, select GPU backends, + infer topic importance, or emit invented birth/split/merge events. + Lineage events remain producer-supplied. + +This is posterior topic coordinates, not importance and not a sampler. + +## Alternatives considered + +1. Bind another refusal or lineage-criterion profile — rejected because + those binds are already live as separate analysis-run profiles. +2. Invent a Bayesian sampler or topic birth/split/merge engine — rejected + because those functions do not exist on protected main as executors. +3. Collapse missing draws into a point estimate — rejected because the + producer contract already fails closed on incomplete draw sets. +4. Bind the existing producer validator to ADR 0022's analysis-run + profile — accepted. + +## Consequences + +Operators can request cutoff-safe posterior topic-context validation as a +digest-bound terminal result. The artifact does not claim topic +importance, Bayesian sampling, GPU parity, or invented birth/split/merge. +Snapshot/profile/cutoff mismatch and producer-contract refusal fail +closed. + +## Verification + +The PR includes Rust integration tests for successful digest-bound +coordinates, incomplete draw refusal, run-identity mismatch, +snapshot/profile/cutoff mismatch including reuse of live sibling +profiles. Run: + +```text +cargo fmt --all -- --check +cargo test -p analysis_engine +cargo clippy -p analysis_engine --all-targets -- -D warnings +python3 scripts/validate_documentation.py +``` + +## Rollback and supersession + +Rollback removes the `topic_context_posterior_v1` profile. No persisted +schema migration is introduced. Supersede only with an ADR that keeps +posterior coordinates distinct from importance, sampling, and invented +lineage events. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1254c8079..eabb1c7d2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,6 +28,7 @@ Read [`ADR_POLICY.md`](ADR_POLICY.md) first. **Decision status and implementatio | [0020](0020-span-grounded-semantic-units.md) | Span-grounded semantic units; language tags are not identity | Accepted | active-PR | First ADR 0004 production slice; concept alignment, invariance, and topic estimation are not claimed. | | [0021](0021-lineageweave-project-history-boundary.md) | LineageWeave project-history service boundary | Accepted | active-PR | Credential-free bounded project-history API preserves LineageWeave authorization ownership. | | [0022](0022-deterministic-analysis-run-execution.md) | Deterministic cutoff-safe analysis-run execution | Accepted | active-PR | Closes the first executable product path from accepted run to digest-bound terminal result without claiming estimator authority. | +| [0068](0068-topic-context-posterior-analysis-run.md) | Posterior topic-context as an analysis-run profile | Accepted | active-PR | Complements ADR 0022/0024; posterior coordinates, not importance and not a Bayesian sampler. | | [0024](0024-lineage-pair-criterion-and-project-journey-posterior.md) | Independent Event Lineage pair criterion and posterior Project Journey | Proposed | active-PR | Strict artifacts preserve criterion/event-time draws, branches, ties, and CPU/GPU receipts without claiming the scientific estimator is complete. | | [0025](0025-macos-native-rust-mlx-metal-boundary.md) | macOS-native Rust-owned MLX Metal execution | Accepted | accepted-target | Compose authenticates to a native host service; Linux never claims Metal, and actual backend/parity receipts fail closed. | | [0023](0023-lineage-criterion-anchor-contract.md) | TEPP-owned Event Lineage criterion anchor | Accepted | active-PR | PR #237 publishes the strict accepted/rejected artifact and identities; estimator execution remains fail-closed future work. | @@ -138,6 +139,7 @@ Use the narrowest owning ADR when decisions overlap: - **project-history wire-size symmetry:** ADR 0019. - **LineageWeave project-history service boundary:** ADR 0021. - **accepted-run execution and terminal artifact production:** ADR 0022. +- **posterior topic-context analysis-run claim boundary:** ADR 0068. - **independent lineage criterion and posterior Project Journey:** ADR 0023. - **macOS-native Rust-owned MLX Metal execution:** ADR 0024. diff --git a/docs/doctoring/topic-context-posterior-analysis-run.md b/docs/doctoring/topic-context-posterior-analysis-run.md new file mode 100644 index 000000000..c7d982d54 --- /dev/null +++ b/docs/doctoring/topic-context-posterior-analysis-run.md @@ -0,0 +1,16 @@ +# Posterior topic-context analysis-run composition + +**Active slice:** ADR 0068 / `topic_context_posterior_v1` +**Protected-main status:** not implemented-main + +`analysis_engine` already validates digest-bound posterior topic-context +artifacts through `TopicContextPosteriorArtifact`. This slice binds that +producer contract to a cutoff-safe analysis-run profile so an operator can +request a digest-bound terminal result. + +The executor does not infer topic importance, does not collapse missing +draws, and does not invent birth/split/merge events. Lineage events remain +producer-supplied. It is not a Bayesian sampler and not GPU execution. + +Exact-head Checks and two independent approvals are required before any +implemented-main claim. From 3e09ff29cc89ef97a859f3ae50e1297846dd2eeb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 1 Sep 2026 11:49:44 +0900 Subject: [PATCH 02/14] fix(analysis): bind posterior runs to eligible snapshots --- CHANGELOG.md | 2 +- crates/analysis_engine/src/lib.rs | 7 +- .../src/topic_context_posterior.rs | 70 ++++- ...ic_context_posterior_execution_contract.rs | 270 ++++++++++++++++-- ...68-topic-context-posterior-analysis-run.md | 18 +- .../topic-context-posterior-analysis-run.md | 6 + 6 files changed, 332 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ba332e2..e13b234cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ All notable changes to TEPP are documented here. The format follows Keep a Chang ## [Unreleased] -- **Posterior topic-context analysis-run profile**: cutoff-safe `topic_context_posterior_v1` binds `TopicContextPosteriorArtifact` and refuses importance, collapsed draws, and invented birth/split/merge (`analysis_engine`). Not a Bayesian sampler and not implemented-main. +- **Posterior topic-context analysis-run profile**: cutoff-safe `topic_context_posterior_v1` binds `TopicContextPosteriorArtifact` to an authoritative source/artifact digest manifest, rejects missing or post-cutoff document availability and unapproved producer contracts, derives the validated coordinate count, and refuses importance, collapsed draws, and invented birth/split/merge (`analysis_engine`). Not a Bayesian sampler and not implemented-main. - `event_core` adds bounded Allen interval-consistency classification, atomic path-consistency closure, contradiction/resource refusals, and an explicit dependency-error fallback without claiming unrestricted global satisfiability. diff --git a/crates/analysis_engine/src/lib.rs b/crates/analysis_engine/src/lib.rs index 960fa9d3b..741f10163 100644 --- a/crates/analysis_engine/src/lib.rs +++ b/crates/analysis_engine/src/lib.rs @@ -51,9 +51,10 @@ pub use lineage_criterion::{ /// Bounded posterior topic-context producer contract and record types. pub use topic_context_posterior::{ TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION, - TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, - TopicActivityInterval, TopicContextMembership, TopicContextPosteriorArtifact, - TopicContextPosteriorExecution, TopicDocumentRelation, TopicLineageEvent, + TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE, TOPIC_CONTEXT_POSTERIOR_PRODUCER_CONTRACT_VERSION, + TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, TopicActivityInterval, TopicContextMembership, + TopicContextPosteriorArtifact, TopicContextPosteriorExecution, + TopicContextPosteriorSnapshotManifest, TopicDocumentRelation, TopicLineageEvent, TopicPostPlausibleValue, execute_topic_context_posterior_run, }; /// Topic-lineage artifact and execution contracts from this engine. diff --git a/crates/analysis_engine/src/topic_context_posterior.rs b/crates/analysis_engine/src/topic_context_posterior.rs index e5da785db..1297613e6 100644 --- a/crates/analysis_engine/src/topic_context_posterior.rs +++ b/crates/analysis_engine/src/topic_context_posterior.rs @@ -19,6 +19,8 @@ pub const TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION: &str = "tepp.topic_context_pos pub const TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT: usize = 16 * 1024 * 1024; /// Model contract required by the topic-context posterior analysis-run path. pub const TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION: &str = "topic_context_posterior_v1"; +/// Producer model contract accepted by the analysis-run profile. +pub const TOPIC_CONTEXT_POSTERIOR_PRODUCER_CONTRACT_VERSION: &str = "trsl-tm-v1"; /// Analysis-run output profile required for a topic-context posterior artifact. pub const TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE: &str = "topic_context_posterior_v1"; const TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS: &str = "posterior_topic_coordinates_not_importance"; @@ -159,6 +161,21 @@ pub struct TopicContextPosteriorArtifact { pub inference_status: String, } +/// Authoritative snapshot and cutoff-eligibility manifest for one artifact. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TopicContextPosteriorSnapshotManifest { + /// Immutable source snapshot identity. + pub snapshot_id: String, + /// Canonical digest of the resolved source snapshot bytes. + pub source_snapshot_sha256: String, + /// Historical cutoff applied while resolving eligibility. + pub knowledge_cutoff: String, + /// Canonical digest of the exact artifact admitted from this snapshot. + pub artifact_sha256: String, + /// Availability instant for every document represented by the artifact. + pub document_available_at: BTreeMap, +} + fn digest(value: &str) -> bool { value.len() == 64 && value @@ -669,40 +686,65 @@ pub struct TopicContextPosteriorExecution { pub fn execute_topic_context_posterior_run( request: &AnalysisRunRequest, accepted: &AnalysisRunAccepted, - snapshot_id: &str, - knowledge_cutoff: KnowledgeCutoff, + manifest: &TopicContextPosteriorSnapshotManifest, artifact: &TopicContextPosteriorArtifact, completed_at: impl Into, ) -> Result { request.to_json()?; accepted.to_json()?; require_receipt_identity(request, accepted)?; - if request.snapshot_id != snapshot_id || artifact.snapshot_id != snapshot_id { + if request.snapshot_id != manifest.snapshot_id || artifact.snapshot_id != manifest.snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } - if request.knowledge_cutoff != knowledge_cutoff.to_rfc3339() - || artifact.knowledge_cutoff != knowledge_cutoff.to_rfc3339() + let knowledge_cutoff = + canonical_time(&manifest.knowledge_cutoff).ok_or(AnalysisEngineError::InvalidEvidence)?; + if request.knowledge_cutoff != manifest.knowledge_cutoff + || artifact.knowledge_cutoff != manifest.knowledge_cutoff + || artifact.source_snapshot_sha256 != manifest.source_snapshot_sha256 || request.model_contract_version != TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION || request.output_profile != TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE + || artifact.model_contract_version != TOPIC_CONTEXT_POSTERIOR_PRODUCER_CONTRACT_VERSION || artifact.run_id != accepted.run_id || artifact.inference_status != TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS + || !digest(&manifest.source_snapshot_sha256) + || !digest(&manifest.artifact_sha256) { return Err(AnalysisEngineError::InvalidEvidence); } let digest = artifact.sha256()?; + if digest != manifest.artifact_sha256 { + return Err(AnalysisEngineError::InvalidEvidence); + } let mut document_ids = BTreeSet::new(); for value in &artifact.plausible_values { - document_ids.insert(value.document_id.as_str()); + document_ids.insert(value.document_id.clone()); } - let document_count = - u64::try_from(document_ids.len()).map_err(|_| AnalysisEngineError::ArithmeticOverflow)?; - let summary = AnalysisResultSummary::new( - "topic_context_posterior", - document_count, - 3, - TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS, - )?; + if document_ids.len() != manifest.document_available_at.len() + || document_ids.iter().any(|document_id| { + manifest + .document_available_at + .get(document_id) + .and_then(|available_at| canonical_time(available_at)) + .is_none_or(|available_at| available_at > knowledge_cutoff) + }) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + // Artifact validation bounds the canonical payload to 16 MiB, so both + // counts are far below the public summary limit and fit in u64. + let document_count = document_ids.len() as u64; + let statistic_count = artifact + .plausible_values + .iter() + .map(|value| value.logistic_normal_coordinates.len() as u64) + .sum(); + let summary = AnalysisResultSummary { + analysis_family: "topic_context_posterior".into(), + evidence_count: document_count, + statistic_count, + validation_status: TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS.into(), + }; let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, diff --git a/crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs b/crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs index d7ce40bf2..e2c3b8d31 100644 --- a/crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs +++ b/crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs @@ -1,18 +1,16 @@ //! End-to-end contract for cutoff-safe posterior topic-context analysis-run. +use std::collections::BTreeMap; + use analysis_engine::{ AnalysisEngineError, TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION, TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, TopicActivityInterval, TopicContextMembership, TopicContextPosteriorArtifact, - TopicDocumentRelation, TopicPostPlausibleValue, execute_topic_context_posterior_run, + TopicContextPosteriorSnapshotManifest, TopicDocumentRelation, TopicPostPlausibleValue, + execute_topic_context_posterior_run, }; -use temporal_core::KnowledgeCutoff; use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalState}; -fn cutoff() -> KnowledgeCutoff { - KnowledgeCutoff::parse_rfc3339("2026-08-01T00:00:00Z").expect("cutoff") -} - fn artifact() -> TopicContextPosteriorArtifact { let documents = [ "018f3f7a-7b7c-7d00-8000-000000000001", @@ -99,6 +97,25 @@ fn request() -> AnalysisRunRequest { } } +fn manifest(artifact: &TopicContextPosteriorArtifact) -> TopicContextPosteriorSnapshotManifest { + TopicContextPosteriorSnapshotManifest { + snapshot_id: artifact.snapshot_id.clone(), + source_snapshot_sha256: artifact.source_snapshot_sha256.clone(), + knowledge_cutoff: artifact.knowledge_cutoff.clone(), + artifact_sha256: artifact.sha256().expect("artifact digest"), + document_available_at: BTreeMap::from([ + ( + "018f3f7a-7b7c-7d00-8000-000000000001".into(), + "2026-07-20T00:00:00Z".into(), + ), + ( + "018f3f7a-7b7c-7d00-8000-000000000002".into(), + "2026-08-01T00:00:00Z".into(), + ), + ]), + } +} + fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { AnalysisRunAccepted::new( "run-topic-context-posterior", @@ -111,12 +128,12 @@ fn accepted(request: &AnalysisRunRequest) -> AnalysisRunAccepted { fn execute( request: &AnalysisRunRequest, ) -> Result { + let artifact = artifact(); execute_topic_context_posterior_run( request, &accepted(request), - "snapshot-topic-context-posterior", - cutoff(), - &artifact(), + &manifest(&artifact), + &artifact, "2026-08-02T00:00:00Z", ) } @@ -147,6 +164,15 @@ fn validated_posterior_emits_digest_without_claiming_importance() { execution.terminal_result.result_schema_version.as_deref(), Some(TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION) ); + assert_eq!( + execution + .terminal_result + .summary + .as_ref() + .expect("summary") + .statistic_count, + 4 + ); } #[test] @@ -154,25 +180,39 @@ fn producer_contract_refusal_and_run_identity_mismatch_fail_closed() { let request = request(); let mut invalid = artifact(); invalid.plausible_values.pop(); + let invalid_manifest = manifest(&artifact()); assert_eq!( execute_topic_context_posterior_run( &request, &accepted(&request), - "snapshot-topic-context-posterior", - cutoff(), + &invalid_manifest, &invalid, "2026-08-02T00:00:00Z", ), Err(AnalysisEngineError::InvalidEvidence) ); + + let original_artifact = artifact(); + let mut artifact_snapshot_mismatch = original_artifact.clone(); + artifact_snapshot_mismatch.snapshot_id = "other-snapshot".into(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &manifest(&original_artifact), + &artifact_snapshot_mismatch, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::SnapshotMismatch) + ); let mut mismatched_run = artifact(); mismatched_run.run_id = "other-run".into(); + let mismatched_manifest = manifest(&mismatched_run); assert_eq!( execute_topic_context_posterior_run( &request, &accepted(&request), - "snapshot-topic-context-posterior", - cutoff(), + &mismatched_manifest, &mismatched_run, "2026-08-02T00:00:00Z", ), @@ -183,13 +223,15 @@ fn producer_contract_refusal_and_run_identity_mismatch_fail_closed() { #[test] fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { let request = request(); + let artifact = artifact(); + let mut mismatched_manifest = manifest(&artifact); + mismatched_manifest.snapshot_id = "other-snapshot".into(); assert_eq!( execute_topic_context_posterior_run( &request, &accepted(&request), - "other-snapshot", - cutoff(), - &artifact(), + &mismatched_manifest, + &artifact, "2026-08-02T00:00:00Z", ), Err(AnalysisEngineError::SnapshotMismatch) @@ -242,3 +284,199 @@ fn execution_refuses_snapshot_profile_and_cutoff_mismatch() { ); } } + +#[test] +fn execution_binds_snapshot_digest_availability_and_producer_contract() { + let request = request(); + let artifact = artifact(); + + let mut wrong_snapshot_digest = manifest(&artifact); + wrong_snapshot_digest.source_snapshot_sha256 = "1".repeat(64); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &wrong_snapshot_digest, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut future_evidence = manifest(&artifact); + future_evidence.document_available_at.insert( + "018f3f7a-7b7c-7d00-8000-000000000001".into(), + "2026-08-01T00:00:01Z".into(), + ); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &future_evidence, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut foreign_producer = artifact.clone(); + foreign_producer.model_contract_version = "unapproved-producer-v1".into(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &manifest(&foreign_producer), + &foreign_producer, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + for invalid_artifact in [ + { + let mut value = artifact.clone(); + value.knowledge_cutoff = "2026-07-31T23:59:59Z".into(); + value + }, + { + let mut value = artifact.clone(); + value.inference_status = "topic_importance".into(); + value + }, + ] { + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &manifest(&artifact), + &invalid_artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + } +} + +#[test] +fn execution_rejects_unbound_artifact_and_availability_manifests() { + let request = request(); + let artifact = artifact(); + + let mut wrong_artifact_digest = manifest(&artifact); + wrong_artifact_digest.artifact_sha256 = "1".repeat(64); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &wrong_artifact_digest, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut incomplete_availability = manifest(&artifact); + incomplete_availability.document_available_at.pop_first(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &incomplete_availability, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut malformed_availability = manifest(&artifact); + *malformed_availability + .document_available_at + .first_entry() + .expect("document") + .get_mut() = "not-a-time".into(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &malformed_availability, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut malformed_manifest_digest = manifest(&artifact); + malformed_manifest_digest.artifact_sha256 = "not-a-digest".into(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &malformed_manifest_digest, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut malformed_source_digest = manifest(&artifact); + malformed_source_digest.source_snapshot_sha256 = "not-a-digest".into(); + let mut matching_malformed_source = artifact.clone(); + matching_malformed_source.source_snapshot_sha256 = "not-a-digest".into(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &malformed_source_digest, + &matching_malformed_source, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut substituted_availability = manifest(&artifact); + substituted_availability.document_available_at.pop_first(); + substituted_availability.document_available_at.insert( + "018f3f7a-7b7c-7d00-8000-000000000099".into(), + "2026-07-20T00:00:00Z".into(), + ); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &substituted_availability, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); +} + +#[test] +fn execution_rejects_malformed_manifest_and_completion_times() { + let request = request(); + let artifact = artifact(); + + let mut malformed_cutoff = manifest(&artifact); + malformed_cutoff.knowledge_cutoff = "not-a-time".into(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &malformed_cutoff, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + assert!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &manifest(&artifact), + &artifact, + "not-a-time", + ) + .is_err() + ); +} diff --git a/docs/adr/0068-topic-context-posterior-analysis-run.md b/docs/adr/0068-topic-context-posterior-analysis-run.md index 555e032ca..bce2dbf69 100644 --- a/docs/adr/0068-topic-context-posterior-analysis-run.md +++ b/docs/adr/0068-topic-context-posterior-analysis-run.md @@ -29,13 +29,17 @@ Add the `topic_context_posterior_v1` analysis-run output profile to `analysis_engine`. The executor: - consumes an already-constructed `TopicContextPosteriorArtifact`; -- requires the request snapshot, knowledge cutoff, and accepted run - identity to match the offered artifact; +- requires an authoritative snapshot manifest to bind the request and artifact + snapshot identity, source digest, cutoff, exact artifact digest, and every + represented document's availability time; +- rejects missing, extra, malformed, or post-cutoff document availability and + artifacts not emitted under the approved `trsl-tm-v1` producer contract; - invokes the existing producer `sha256`/validate path without reimplementing TRSL-TM fitting; - emits a digest-bound terminal result under `tepp.topic_context_posterior.v1` with inference status - `posterior_topic_coordinates_not_importance`; + `posterior_topic_coordinates_not_importance` and counts the coordinates + actually present rather than a fixed statistic count; - refuses reuse of `lineage_criterion_v1`, `case_deletion_refit_v1`, `composed_fitted_lineage_v1`, `fitted_candidate_k_v1`, `trsl_topic_lineage_v1`, and `method_effects_v1` as this profile; @@ -61,15 +65,15 @@ This is posterior topic coordinates, not importance and not a sampler. Operators can request cutoff-safe posterior topic-context validation as a digest-bound terminal result. The artifact does not claim topic importance, Bayesian sampling, GPU parity, or invented birth/split/merge. -Snapshot/profile/cutoff mismatch and producer-contract refusal fail -closed. +Snapshot/profile/cutoff/digest mismatch, incomplete cutoff eligibility, and +producer-contract refusal fail closed. ## Verification The PR includes Rust integration tests for successful digest-bound coordinates, incomplete draw refusal, run-identity mismatch, -snapshot/profile/cutoff mismatch including reuse of live sibling -profiles. Run: +snapshot/profile/cutoff/source/artifact-digest mismatch, future evidence, +producer-contract mismatch, and reuse of live sibling profiles. Run: ```text cargo fmt --all -- --check diff --git a/docs/doctoring/topic-context-posterior-analysis-run.md b/docs/doctoring/topic-context-posterior-analysis-run.md index c7d982d54..c4a365bed 100644 --- a/docs/doctoring/topic-context-posterior-analysis-run.md +++ b/docs/doctoring/topic-context-posterior-analysis-run.md @@ -8,6 +8,12 @@ artifacts through `TopicContextPosteriorArtifact`. This slice binds that producer contract to a cutoff-safe analysis-run profile so an operator can request a digest-bound terminal result. +Execution requires one authoritative snapshot manifest. It binds the source +snapshot digest and exact artifact digest, provides an availability instant for +every represented document, rejects evidence available after the historical +cutoff, and admits only the `trsl-tm-v1` producer contract. The terminal summary +counts the logistic-normal coordinates actually validated. + The executor does not infer topic importance, does not collapse missing draws, and does not invent birth/split/merge events. Lineage events remain producer-supplied. It is not a Bayesian sampler and not GPU execution. From b44f8bc5c88504b6cc3b1f3631bc406aa44fd23a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 14:07:34 +0900 Subject: [PATCH 03/14] test(analysis): pin topic posterior cutoff instant semantics --- ...pic_context_posterior_temporal_contract.rs | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 crates/analysis_engine/tests/topic_context_posterior_temporal_contract.rs diff --git a/crates/analysis_engine/tests/topic_context_posterior_temporal_contract.rs b/crates/analysis_engine/tests/topic_context_posterior_temporal_contract.rs new file mode 100644 index 000000000..a7cfa6de3 --- /dev/null +++ b/crates/analysis_engine/tests/topic_context_posterior_temporal_contract.rs @@ -0,0 +1,159 @@ +//! Regression contracts for topic-context posterior temporal binding. + +use std::collections::BTreeMap; + +use analysis_engine::{ + TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION, TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE, + TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, TopicActivityInterval, TopicContextMembership, + TopicContextPosteriorArtifact, TopicContextPosteriorSnapshotManifest, TopicDocumentRelation, + TopicPostPlausibleValue, execute_topic_context_posterior_run, +}; +use tepp_api::{AnalysisRunAccepted, AnalysisRunRequest}; + +fn artifact() -> TopicContextPosteriorArtifact { + let documents = [ + "018f3f7a-7b7c-7d00-8000-000000000001", + "018f3f7a-7b7c-7d00-8000-000000000002", + ]; + TopicContextPosteriorArtifact { + schema_version: TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION.into(), + run_id: "run-topic-context-posterior".into(), + snapshot_id: "snapshot-topic-context-posterior".into(), + source_snapshot_sha256: "0".repeat(64), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + event_clock_code: "event_time_rfc3339".into(), + model_contract_version: "trsl-tm-v1".into(), + posterior_draw_set_id: "draw-set-1".into(), + posterior_draw_count: 2, + topic_count: 2, + topic_ids: vec![ + "018f3f7a-7b7c-7d00-8000-000000000101".into(), + "018f3f7a-7b7c-7d00-8000-000000000102".into(), + ], + activity_intervals: [ + "018f3f7a-7b7c-7d00-8000-000000000101", + "018f3f7a-7b7c-7d00-8000-000000000102", + ] + .map(|topic_id| TopicActivityInterval { + topic_id: topic_id.into(), + state_code: "active".into(), + valid_from: "2026-07-01T00:00:00Z".into(), + valid_to: "2026-07-15T00:00:00Z".into(), + }) + .into(), + lineage_events: vec![], + document_relations: vec![TopicDocumentRelation { + source_document_id: documents[0].into(), + target_document_id: documents[1].into(), + relation_kind_code: "event_lineage_precedes".into(), + event_time: "2026-07-15T00:00:00Z".into(), + evidence_sha256: "c".repeat(64), + evidence_resource_id: "evidence-relation-1".into(), + provenance_assertion_id: "provenance-relation-1".into(), + }], + plausible_values: documents + .iter() + .flat_map(|document| { + (0..2).map(|draw| TopicPostPlausibleValue { + document_id: (*document).into(), + draw_index: draw, + event_time: "2026-07-15T00:00:00Z".into(), + logistic_normal_coordinates: vec![if draw == 0 { 0.0 } else { 0.1 }], + }) + }) + .collect(), + memberships: documents + .iter() + .flat_map(|document| { + ["business_unit", "process_unit", "team", "person"].map(|dimension| { + TopicContextMembership { + document_id: (*document).into(), + dimension_code: dimension.into(), + context_id: format!("{dimension}-{document}"), + weight: 1.0, + valid_from: "2026-07-01T00:00:00Z".into(), + valid_to: "2026-08-01T00:00:00Z".into(), + evidence_sha256: "b".repeat(64), + evidence_resource_id: format!("evidence-{dimension}-{document}"), + provenance_assertion_id: format!("provenance-{dimension}-{document}"), + } + }) + }) + .collect(), + inference_status: "posterior_topic_coordinates_not_importance".into(), + } +} + +fn request(cutoff: &str) -> AnalysisRunRequest { + AnalysisRunRequest { + contract_version: 1, + idempotency_key: "topic-context-posterior-temporal-idem".into(), + tenant_workspace_id: "tenant-workspace".into(), + snapshot_id: "snapshot-topic-context-posterior".into(), + knowledge_cutoff: cutoff.into(), + model_contract_version: TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION.into(), + output_profile: TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE.into(), + } +} + +fn manifest(artifact: &TopicContextPosteriorArtifact) -> TopicContextPosteriorSnapshotManifest { + TopicContextPosteriorSnapshotManifest { + snapshot_id: artifact.snapshot_id.clone(), + source_snapshot_sha256: artifact.source_snapshot_sha256.clone(), + knowledge_cutoff: artifact.knowledge_cutoff.clone(), + artifact_sha256: artifact.sha256().expect("artifact digest"), + document_available_at: BTreeMap::from([ + ( + "018f3f7a-7b7c-7d00-8000-000000000001".into(), + "2026-07-20T00:00:00Z".into(), + ), + ( + "018f3f7a-7b7c-7d00-8000-000000000002".into(), + "2026-08-01T00:00:00Z".into(), + ), + ]), + } +} + +fn execute(cutoff: &str) -> analysis_engine::TopicContextPosteriorExecution { + let request = request(cutoff); + let artifact = artifact(); + let accepted = AnalysisRunAccepted::new( + "run-topic-context-posterior", + "accepted", + &request.idempotency_key, + ) + .expect("accepted"); + execute_topic_context_posterior_run( + &request, + &accepted, + &manifest(&artifact), + &artifact, + "2026-08-02T00:00:00Z", + ) + .expect("equivalent cutoff must execute") +} + +#[test] +fn equivalent_rfc3339_cutoff_instants_are_the_same_contract() { + let execution = execute("2026-08-01T01:00:00+01:00"); + assert_eq!(execution.artifact.knowledge_cutoff, "2026-08-01T00:00:00Z"); +} + +#[test] +fn terminal_validation_status_is_not_the_scientific_inference_claim() { + let execution = execute("2026-08-01T00:00:00Z"); + assert_eq!( + execution + .terminal_result + .summary + .as_ref() + .expect("summary") + .validation_status, + "validated" + ); + assert_eq!( + execution.artifact.inference_status, + "posterior_topic_coordinates_not_importance" + ); +} From 6f3eae7627825a5d04da0371c321ef9639c3ea8f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 14:08:37 +0900 Subject: [PATCH 04/14] fix(analysis): compare posterior cutoffs by instant --- .../src/topic_context_posterior.rs | 1430 ++-------------- .../src/topic_context_posterior_base.rs | 1451 +++++++++++++++++ 2 files changed, 1546 insertions(+), 1335 deletions(-) create mode 100644 crates/analysis_engine/src/topic_context_posterior_base.rs diff --git a/crates/analysis_engine/src/topic_context_posterior.rs b/crates/analysis_engine/src/topic_context_posterior.rs index 1297613e6..3dc284d04 100644 --- a/crates/analysis_engine/src/topic_context_posterior.rs +++ b/crates/analysis_engine/src/topic_context_posterior.rs @@ -1,168 +1,56 @@ -//! Posterior TRSL-TM producer contract for downstream context influence. +//! Topic-context posterior analysis-run boundary. +//! +//! The producer-owned artifact schema remains in the adjacent base module; +//! this boundary adds analysis-run temporal and snapshot-manifest admission. use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use temporal_core::KnowledgeCutoff; -use uuid::Uuid; - +use temporal_core::{AvailableTime, KnowledgeCutoff}; use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, }; use crate::{AnalysisEngineError, format_digest, require_receipt_identity, valid_identifier}; -/// Exact posterior artifact schema. -pub const TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION: &str = "tepp.topic_context_posterior.v1"; -/// Maximum canonical JSON size. -pub const TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT: usize = 16 * 1024 * 1024; -/// Model contract required by the topic-context posterior analysis-run path. -pub const TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION: &str = "topic_context_posterior_v1"; -/// Producer model contract accepted by the analysis-run profile. -pub const TOPIC_CONTEXT_POSTERIOR_PRODUCER_CONTRACT_VERSION: &str = "trsl-tm-v1"; -/// Analysis-run output profile required for a topic-context posterior artifact. -pub const TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE: &str = "topic_context_posterior_v1"; -const TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS: &str = "posterior_topic_coordinates_not_importance"; -const ENTRY_LIMIT: usize = 1_000_000; -const DIMENSIONS: [&str; 4] = ["business_unit", "process_unit", "team", "person"]; -type PosteriorDraws = BTreeMap>; -type DocumentEventTimes = BTreeMap; - -/// One explicit active, dormant, or reactivated interval for a global topic. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct TopicActivityInterval { - /// Opaque stable topic identity. - pub topic_id: String, - /// Activity state: `active`, `dormant`, or `reactivated`. - pub state_code: String, - /// Inclusive event-time start. - pub valid_from: String, - /// Inclusive event-time end. - pub valid_to: String, +mod base { + include!("topic_context_posterior_base.rs"); } -/// One explicit topic birth/split/merge/retirement event, when fitted. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct TopicLineageEvent { - /// Event kind supplied by TEPP, never inferred by a consumer. - pub event_code: String, - /// Source stable topic identity. - pub source_topic_id: String, - /// Optional target stable topic identity. - pub target_topic_id: Option, - /// Event time. - pub event_time: String, - /// Digest of event evidence. - pub evidence_sha256: String, - /// Opaque evidence resource identity. - pub evidence_resource_id: String, - /// Opaque provenance assertion identity. - pub provenance_assertion_id: String, -} +pub use base::{ + TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION, + TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE, TOPIC_CONTEXT_POSTERIOR_PRODUCER_CONTRACT_VERSION, + TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, TopicActivityInterval, TopicContextMembership, + TopicContextPosteriorArtifact, TopicContextPosteriorExecution, TopicDocumentRelation, + TopicLineageEvent, TopicPostPlausibleValue, +}; -/// One admitted Event Lineage or document-relation record. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct TopicDocumentRelation { - /// Opaque source document identity. - pub source_document_id: String, - /// Opaque target document identity. - pub target_document_id: String, - /// Producer-owned closed relation kind. - pub relation_kind_code: String, - /// Event time at which the relation is admitted. - pub event_time: String, - /// Digest of relation evidence. - pub evidence_sha256: String, - /// Opaque evidence resource identity. - pub evidence_resource_id: String, - /// Opaque provenance assertion identity. - pub provenance_assertion_id: String, -} +const MANIFEST_ENTRY_LIMIT: usize = 1_000_000; +const TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS: &str = + "posterior_topic_coordinates_not_importance"; -/// One document posterior plausible value for one draw. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct TopicPostPlausibleValue { - /// Opaque document identity. - pub document_id: String, - /// Posterior draw index. - pub draw_index: u64, - /// Event time used by the model. - pub event_time: String, - /// Full-rank logistic-normal coordinates of length `topic_count - 1`. - pub logistic_normal_coordinates: Vec, +fn digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } -/// One time-valid provenance-bound organizational membership. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct TopicContextMembership { - /// Opaque document identity. - pub document_id: String, - /// `business_unit`, `process_unit`, `team`, or `person`. - pub dimension_code: String, - /// Opaque context identity within the dimension. - pub context_id: String, - /// Source-derived multiple-membership weight. - pub weight: f64, - /// Inclusive event-time validity start. - pub valid_from: String, - /// Inclusive event-time validity end. - pub valid_to: String, - /// Digest of membership source evidence. - pub evidence_sha256: String, - /// Opaque evidence resource identity. - pub evidence_resource_id: String, - /// Opaque provenance assertion identity. - pub provenance_assertion_id: String, +fn canonical_cutoff(value: &str) -> Option { + KnowledgeCutoff::parse_rfc3339(value) + .ok() + .filter(|instant| instant.to_rfc3339() == value) } -/// Digest-bound posterior artifact consumed by fast-mlsirm. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -#[serde(deny_unknown_fields)] -pub struct TopicContextPosteriorArtifact { - /// Exact schema identity. - pub schema_version: String, - /// Opaque model-run identity. - pub run_id: String, - /// Immutable source snapshot identity. - pub snapshot_id: String, - /// Canonical source snapshot SHA-256. - pub source_snapshot_sha256: String, - /// Historical knowledge cutoff. - pub knowledge_cutoff: String, - /// Exact event-time clock represented by all temporal fields. - pub event_clock_code: String, - /// Exact model contract version. - pub model_contract_version: String, - /// Opaque posterior draw-set identity. - pub posterior_draw_set_id: String, - /// Number of draws present for every document. - pub posterior_draw_count: u64, - /// Number of global topics. - pub topic_count: u64, - /// Stable topic identities in logistic-normal coordinate order. - pub topic_ids: Vec, - /// Explicit topic-state intervals. - pub activity_intervals: Vec, - /// Explicit topic lineage events, when present. - pub lineage_events: Vec, - /// Complete admitted Event Lineage/document relations. - pub document_relations: Vec, - /// Complete document-by-draw posterior coordinates. - pub plausible_values: Vec, - /// Time-valid BU/PU/team/person memberships. - pub memberships: Vec, - /// Fixed interpretation boundary. - pub inference_status: String, +fn canonical_available_time(value: &str) -> Option { + AvailableTime::parse_rfc3339(value) + .ok() + .filter(|instant| instant.to_rfc3339() == value) } /// Authoritative snapshot and cutoff-eligibility manifest for one artifact. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] pub struct TopicContextPosteriorSnapshotManifest { /// Immutable source snapshot identity. pub snapshot_id: String, @@ -176,508 +64,60 @@ pub struct TopicContextPosteriorSnapshotManifest { pub document_available_at: BTreeMap, } -fn digest(value: &str) -> bool { - value.len() == 64 - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) -} - -fn provenance_binding(fields: &[&[u8]]) -> String { - let mut digest = Sha256::new(); - for field in fields { - digest.update( - u64::try_from(field.len()) - .expect("bounded artifact field length fits u64") - .to_le_bytes(), - ); - digest.update(field); - } - format_digest(digest.finalize()) -} - -fn time(value: &str) -> Option { - KnowledgeCutoff::parse_rfc3339(value).ok() -} - -fn canonical_time(value: &str) -> Option { - time(value).filter(|instant| instant.to_rfc3339() == value) -} - -fn valid_activity_interval(interval: &TopicActivityInterval, topic_ids: &BTreeSet<&str>) -> bool { - topic_ids.contains(interval.topic_id.as_str()) - && ["active", "dormant", "reactivated"].contains(&interval.state_code.as_str()) - && canonical_time(&interval.valid_from) - .zip(canonical_time(&interval.valid_to)) - .is_some_and(|(valid_from, valid_to)| valid_from <= valid_to) -} - -fn within_entry_limits(lengths: [usize; 5], entry_limit: usize) -> bool { - lengths.into_iter().all(|length| length <= entry_limit) -} - -impl TopicContextPosteriorArtifact { - fn has_valid_header(&self) -> bool { - self.has_valid_header_with_entry_limit(ENTRY_LIMIT) - } - - fn has_valid_header_with_entry_limit(&self, entry_limit: usize) -> bool { - self.schema_version == TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION - && valid_identifier(&self.run_id) - && valid_identifier(&self.snapshot_id) - && digest(&self.source_snapshot_sha256) - && canonical_time(&self.knowledge_cutoff).is_some() - && self.event_clock_code == "event_time_rfc3339" - && valid_identifier(&self.model_contract_version) - && valid_identifier(&self.posterior_draw_set_id) - && self.posterior_draw_count > 0 - && self.topic_count >= 2 - && usize::try_from(self.topic_count) == Ok(self.topic_ids.len()) - && within_entry_limits( - [ - self.activity_intervals.len(), - self.lineage_events.len(), - self.document_relations.len(), - self.plausible_values.len(), - self.memberships.len(), - ], - entry_limit, - ) - && self.inference_status == TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS +impl TopicContextPosteriorSnapshotManifest { + fn validate(&self) -> Result<(), AnalysisEngineError> { + if !valid_identifier(&self.snapshot_id) + || !digest(&self.source_snapshot_sha256) + || !digest(&self.artifact_sha256) + || canonical_cutoff(&self.knowledge_cutoff).is_none() + || self.document_available_at.len() > MANIFEST_ENTRY_LIMIT + || self.document_available_at.iter().any(|(document_id, available_at)| { + !valid_identifier(document_id) + || canonical_available_time(available_at).is_none() + }) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(()) } - /// Parse and validate one bounded posterior artifact. + /// Parse and validate one bounded snapshot manifest. /// /// # Errors /// - /// Returns a size or evidence error for any foreign, incomplete, - /// non-finite, temporally invalid, or mixed-identity payload. + /// Returns a size, wire, or evidence error for an oversized or malformed + /// manifest. pub fn from_json(payload: &str) -> Result { if payload.len() > TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT { return Err(AnalysisEngineError::LimitExceeded); } - let artifact = + let manifest: Self = serde_json::from_str(payload).map_err(|_| AnalysisEngineError::InvalidEvidence)?; - Self::validate(&artifact)?; - Ok(artifact) + manifest.validate()?; + Ok(manifest) } - /// Serialize canonical validated JSON. + /// Serialize one validated snapshot manifest to bounded canonical JSON. /// /// # Errors /// /// Returns a validation, serialization, or size error. pub fn to_json(&self) -> Result { self.validate()?; - let mut canonical = self.clone(); - canonical.activity_intervals.sort_by(|a, b| { - (&a.topic_id, &a.valid_from, &a.valid_to, &a.state_code).cmp(&( - &b.topic_id, - &b.valid_from, - &b.valid_to, - &b.state_code, - )) - }); - canonical.lineage_events.sort_by(|a, b| { - ( - &a.event_code, - &a.source_topic_id, - &a.target_topic_id, - &a.event_time, - &a.evidence_resource_id, - &a.provenance_assertion_id, - ) - .cmp(&( - &b.event_code, - &b.source_topic_id, - &b.target_topic_id, - &b.event_time, - &b.evidence_resource_id, - &b.provenance_assertion_id, - )) - }); - canonical.document_relations.sort_by(|a, b| { - ( - &a.source_document_id, - &a.target_document_id, - &a.relation_kind_code, - &a.event_time, - &a.evidence_resource_id, - &a.provenance_assertion_id, - ) - .cmp(&( - &b.source_document_id, - &b.target_document_id, - &b.relation_kind_code, - &b.event_time, - &b.evidence_resource_id, - &b.provenance_assertion_id, - )) - }); - canonical - .plausible_values - .sort_by(|a, b| (&a.document_id, a.draw_index).cmp(&(&b.document_id, b.draw_index))); - canonical.memberships.sort_by(|a, b| { - ( - &a.document_id, - &a.dimension_code, - &a.context_id, - &a.valid_from, - &a.valid_to, - ) - .cmp(&( - &b.document_id, - &b.dimension_code, - &b.context_id, - &b.valid_from, - &b.valid_to, - )) - }); - let payload = serde_json::to_string(&canonical) - .map_err(|_| AnalysisEngineError::SerializationFailure)?; + let payload = + serde_json::to_string(self).map_err(|_| AnalysisEngineError::SerializationFailure)?; if payload.len() > TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT { return Err(AnalysisEngineError::LimitExceeded); } Ok(payload) } - - /// Return the canonical artifact SHA-256. - /// - /// # Errors - /// - /// Returns a validation or serialization error. - pub fn sha256(&self) -> Result { - self.to_json() - .map(|json| format_digest(Sha256::digest(json.into_bytes()))) - } - - fn validate(&self) -> Result<(), AnalysisEngineError> { - if !self.has_valid_header() { - return Err(AnalysisEngineError::InvalidEvidence); - } - let cutoff = - canonical_time(&self.knowledge_cutoff).ok_or(AnalysisEngineError::InvalidEvidence)?; - let topic_ids: BTreeSet<&str> = self.topic_ids.iter().map(String::as_str).collect(); - if topic_ids.len() != self.topic_ids.len() - || self - .topic_ids - .iter() - .any(|topic_id| Uuid::parse_str(topic_id).is_err()) - { - return Err(AnalysisEngineError::InvalidEvidence); - } - self.validate_topic_records(cutoff, &topic_ids)?; - let (draws, event_times) = self.validate_plausible_values(cutoff)?; - self.validate_document_relations(cutoff, &draws, &event_times)?; - self.validate_memberships(&draws, &event_times)?; - self.validate_provenance_bindings() - } - - fn validate_provenance_bindings(&self) -> Result<(), AnalysisEngineError> { - let mut bindings = BTreeMap::new(); - let mut bind = |id: &str, value: String| { - if bindings - .insert(id.to_owned(), value.clone()) - .is_some_and(|existing| existing != value) - { - Err(AnalysisEngineError::InvalidEvidence) - } else { - Ok(()) - } - }; - for event in &self.lineage_events { - let fields: [&[u8]; 8] = [ - b"topic", - event.event_code.as_bytes(), - event.source_topic_id.as_bytes(), - event.target_topic_id.as_deref().unwrap_or("").as_bytes(), - event.event_time.as_bytes(), - event.evidence_resource_id.as_bytes(), - event.evidence_sha256.as_bytes(), - self.source_snapshot_sha256.as_bytes(), - ]; - bind(&event.provenance_assertion_id, provenance_binding(&fields))?; - } - for relation in &self.document_relations { - let fields: [&[u8]; 8] = [ - b"document", - relation.relation_kind_code.as_bytes(), - relation.source_document_id.as_bytes(), - relation.target_document_id.as_bytes(), - relation.event_time.as_bytes(), - relation.evidence_resource_id.as_bytes(), - relation.evidence_sha256.as_bytes(), - self.source_snapshot_sha256.as_bytes(), - ]; - bind( - &relation.provenance_assertion_id, - provenance_binding(&fields), - )?; - } - for membership in &self.memberships { - let weight = membership.weight.to_bits().to_le_bytes(); - let fields: [&[u8]; 10] = [ - b"membership", - membership.dimension_code.as_bytes(), - membership.document_id.as_bytes(), - membership.context_id.as_bytes(), - &weight, - membership.valid_from.as_bytes(), - membership.valid_to.as_bytes(), - membership.evidence_resource_id.as_bytes(), - membership.evidence_sha256.as_bytes(), - self.source_snapshot_sha256.as_bytes(), - ]; - bind( - &membership.provenance_assertion_id, - provenance_binding(&fields), - )?; - } - Ok(()) - } - - fn validate_topic_records( - &self, - cutoff: KnowledgeCutoff, - topic_ids: &BTreeSet<&str>, - ) -> Result<(), AnalysisEngineError> { - let mut activity_by_topic: BTreeMap<&str, Vec<(KnowledgeCutoff, KnowledgeCutoff, &str)>> = - BTreeMap::new(); - let mut seen_activity = BTreeSet::new(); - for interval in &self.activity_intervals { - if !valid_activity_interval(interval, topic_ids) { - return Err(AnalysisEngineError::InvalidEvidence); - } - let valid_from = - canonical_time(&interval.valid_from).ok_or(AnalysisEngineError::InvalidEvidence)?; - let valid_to = - canonical_time(&interval.valid_to).ok_or(AnalysisEngineError::InvalidEvidence)?; - if valid_to > cutoff { - return Err(AnalysisEngineError::InvalidEvidence); - } - let key = ( - interval.topic_id.as_str(), - valid_from, - valid_to, - interval.state_code.as_str(), - ); - if !seen_activity.insert(key) { - return Err(AnalysisEngineError::InvalidEvidence); - } - activity_by_topic - .entry(&interval.topic_id) - .or_default() - .push((valid_from, valid_to, interval.state_code.as_str())); - } - if activity_by_topic.len() != topic_ids.len() { - return Err(AnalysisEngineError::InvalidEvidence); - } - for intervals in activity_by_topic.values_mut() { - intervals.sort(); - if intervals.first().map(|interval| interval.2) != Some("active") { - return Err(AnalysisEngineError::InvalidEvidence); - } - for pair in intervals.windows(2) { - let valid_transition = matches!( - (pair[0].2, pair[1].2), - ("active" | "reactivated", "dormant") | ("dormant", "reactivated") - ); - if pair[1].0 <= pair[0].1 || !valid_transition { - return Err(AnalysisEngineError::InvalidEvidence); - } - } - } - let mut seen_lineage = BTreeSet::new(); - for event in &self.lineage_events { - let event_time = - canonical_time(&event.event_time).ok_or(AnalysisEngineError::InvalidEvidence)?; - let key = ( - event.event_code.as_str(), - event.source_topic_id.as_str(), - event.target_topic_id.as_deref(), - event_time, - event.evidence_resource_id.as_str(), - event.provenance_assertion_id.as_str(), - ); - if !["birth", "split", "merge", "retirement"].contains(&event.event_code.as_str()) - || !topic_ids.contains(event.source_topic_id.as_str()) - || event - .target_topic_id - .as_deref() - .is_some_and(|target| !topic_ids.contains(target)) - || match event.event_code.as_str() { - "birth" | "retirement" => event.target_topic_id.is_some(), - // Only "split"/"merge" remain; their target must be None - // or self-referencing. - _ => event - .target_topic_id - .as_deref() - .is_none_or(|target| target == event.source_topic_id), - } - || event_time > cutoff - || !digest(&event.evidence_sha256) - || !valid_identifier(&event.evidence_resource_id) - || !valid_identifier(&event.provenance_assertion_id) - || !seen_lineage.insert(key) - { - return Err(AnalysisEngineError::InvalidEvidence); - } - } - Ok(()) - } - - fn validate_plausible_values( - &self, - cutoff: KnowledgeCutoff, - ) -> Result<(PosteriorDraws, DocumentEventTimes), AnalysisEngineError> { - let mut draws: PosteriorDraws = BTreeMap::new(); - let mut event_times = BTreeMap::new(); - for value in &self.plausible_values { - let document = Uuid::parse_str(&value.document_id) - .map_err(|_| AnalysisEngineError::InvalidEvidence)?; - let event_time = - canonical_time(&value.event_time).ok_or(AnalysisEngineError::InvalidEvidence)?; - if value.draw_index >= self.posterior_draw_count - || event_time > cutoff - || value.logistic_normal_coordinates.len() - != usize::try_from(self.topic_count - 1) - .map_err(|_| AnalysisEngineError::InvalidEvidence)? - || value - .logistic_normal_coordinates - .iter() - .any(|coordinate| !coordinate.is_finite()) - || !draws.entry(document).or_default().insert(value.draw_index) - || event_times - .insert(document, event_time) - .is_some_and(|previous| previous != event_time) - { - return Err(AnalysisEngineError::InvalidEvidence); - } - } - if draws.len() < 2 - || draws - .values() - .any(|indices| usize::try_from(self.posterior_draw_count) != Ok(indices.len())) - { - return Err(AnalysisEngineError::InvalidEvidence); - } - Ok((draws, event_times)) - } - - fn validate_document_relations( - &self, - cutoff: KnowledgeCutoff, - draws: &PosteriorDraws, - event_times: &DocumentEventTimes, - ) -> Result<(), AnalysisEngineError> { - let mut seen_relations = BTreeSet::new(); - for relation in &self.document_relations { - let source = Uuid::parse_str(&relation.source_document_id) - .map_err(|_| AnalysisEngineError::InvalidEvidence)?; - let target = Uuid::parse_str(&relation.target_document_id) - .map_err(|_| AnalysisEngineError::InvalidEvidence)?; - let event_time = - canonical_time(&relation.event_time).ok_or(AnalysisEngineError::InvalidEvidence)?; - let key = ( - source, - target, - relation.relation_kind_code.as_str(), - event_time, - relation.evidence_resource_id.as_str(), - relation.provenance_assertion_id.as_str(), - ); - if source == target - || !draws.contains_key(&source) - || !draws.contains_key(&target) - || relation.relation_kind_code != "event_lineage_precedes" - || event_times.get(&source) > event_times.get(&target) - || event_time > cutoff - || !digest(&relation.evidence_sha256) - || !valid_identifier(&relation.evidence_resource_id) - || !valid_identifier(&relation.provenance_assertion_id) - || !seen_relations.insert(key) - { - return Err(AnalysisEngineError::InvalidEvidence); - } - } - Ok(()) - } - - fn validate_memberships( - &self, - draws: &PosteriorDraws, - event_times: &DocumentEventTimes, - ) -> Result<(), AnalysisEngineError> { - let mut dimensions: BTreeMap> = BTreeMap::new(); - let mut seen_memberships = BTreeSet::new(); - for membership in &self.memberships { - let document = Uuid::parse_str(&membership.document_id) - .map_err(|_| AnalysisEngineError::InvalidEvidence)?; - let valid_from = canonical_time(&membership.valid_from) - .ok_or(AnalysisEngineError::InvalidEvidence)?; - let valid_to = - canonical_time(&membership.valid_to).ok_or(AnalysisEngineError::InvalidEvidence)?; - let document_event_time = event_times - .get(&document) - .ok_or(AnalysisEngineError::InvalidEvidence)?; - let dimension_ordinal = DIMENSIONS - .iter() - .position(|dimension| *dimension == membership.dimension_code) - .ok_or(AnalysisEngineError::InvalidEvidence)?; - let key = ( - document, - dimension_ordinal, - membership.context_id.as_str(), - valid_from, - valid_to, - ); - if !valid_identifier(&membership.context_id) - || !membership.weight.is_finite() - || membership.weight <= 0.0 - || valid_from > valid_to - || *document_event_time < valid_from - || *document_event_time > valid_to - || !digest(&membership.evidence_sha256) - || !valid_identifier(&membership.evidence_resource_id) - || !valid_identifier(&membership.provenance_assertion_id) - || !seen_memberships.insert(key) - { - return Err(AnalysisEngineError::InvalidEvidence); - } - dimensions - .entry(document) - .or_default() - .insert(membership.dimension_code.as_str()); - } - if dimensions.len() != draws.len() - || dimensions.values().any(|present| { - DIMENSIONS - .iter() - .any(|required| !present.contains(required)) - }) - { - return Err(AnalysisEngineError::InvalidEvidence); - } - Ok(()) - } -} - -/// One completed topic-context posterior artifact and its terminal result. -#[derive(Clone, Debug, PartialEq)] -pub struct TopicContextPosteriorExecution { - /// Digest-bound producer posterior artifact. - pub artifact: TopicContextPosteriorArtifact, - /// Terminal result carrying the artifact identity, digest, and schema. - pub terminal_result: AnalysisRunTerminalResult, } /// Execute posterior topic-context validation as one analysis-run profile. /// -/// The executor validates an already-constructed -/// [`TopicContextPosteriorArtifact`] through its producer contract and does -/// not reimplement TRSL-TM fitting, collapse missing draws, infer topic -/// importance, or invent birth/split/merge events. Lineage events remain -/// producer-supplied. This is not a Bayesian sampler and not GPU execution. +/// The executor validates an already-constructed producer artifact. Request +/// cutoffs are compared by temporal instant; persisted artifact and manifest +/// cutoffs remain canonical RFC 3339 evidence. /// /// # Errors /// @@ -693,63 +133,73 @@ pub fn execute_topic_context_posterior_run( request.to_json()?; accepted.to_json()?; require_receipt_identity(request, accepted)?; + manifest.validate()?; + if request.snapshot_id != manifest.snapshot_id || artifact.snapshot_id != manifest.snapshot_id { return Err(AnalysisEngineError::SnapshotMismatch); } - let knowledge_cutoff = - canonical_time(&manifest.knowledge_cutoff).ok_or(AnalysisEngineError::InvalidEvidence)?; - if request.knowledge_cutoff != manifest.knowledge_cutoff - || artifact.knowledge_cutoff != manifest.knowledge_cutoff + + let request_cutoff = KnowledgeCutoff::parse_rfc3339(&request.knowledge_cutoff) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + let manifest_cutoff = canonical_cutoff(&manifest.knowledge_cutoff) + .ok_or(AnalysisEngineError::InvalidEvidence)?; + let artifact_cutoff = canonical_cutoff(&artifact.knowledge_cutoff) + .ok_or(AnalysisEngineError::InvalidEvidence)?; + + if request_cutoff.instant() != manifest_cutoff.instant() + || artifact_cutoff.instant() != manifest_cutoff.instant() || artifact.source_snapshot_sha256 != manifest.source_snapshot_sha256 || request.model_contract_version != TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION || request.output_profile != TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE || artifact.model_contract_version != TOPIC_CONTEXT_POSTERIOR_PRODUCER_CONTRACT_VERSION || artifact.run_id != accepted.run_id || artifact.inference_status != TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS - || !digest(&manifest.source_snapshot_sha256) - || !digest(&manifest.artifact_sha256) { return Err(AnalysisEngineError::InvalidEvidence); } - let digest = artifact.sha256()?; - if digest != manifest.artifact_sha256 { + let artifact_digest = artifact.sha256()?; + if artifact_digest != manifest.artifact_sha256 { return Err(AnalysisEngineError::InvalidEvidence); } - let mut document_ids = BTreeSet::new(); - for value in &artifact.plausible_values { - document_ids.insert(value.document_id.clone()); - } + + let document_ids: BTreeSet<&str> = artifact + .plausible_values + .iter() + .map(|value| value.document_id.as_str()) + .collect(); if document_ids.len() != manifest.document_available_at.len() || document_ids.iter().any(|document_id| { manifest .document_available_at - .get(document_id) - .and_then(|available_at| canonical_time(available_at)) - .is_none_or(|available_at| available_at > knowledge_cutoff) + .get(*document_id) + .and_then(|available_at| canonical_available_time(available_at)) + .is_none_or(|available_at| available_at.instant() > manifest_cutoff.instant()) }) { return Err(AnalysisEngineError::InvalidEvidence); } - // Artifact validation bounds the canonical payload to 16 MiB, so both - // counts are far below the public summary limit and fit in u64. - let document_count = document_ids.len() as u64; - let statistic_count = artifact - .plausible_values - .iter() - .map(|value| value.logistic_normal_coordinates.len() as u64) - .sum(); + + let document_count = u64::try_from(document_ids.len()) + .map_err(|_| AnalysisEngineError::LimitExceeded)?; + let statistic_count = artifact.plausible_values.iter().try_fold(0_u64, |total, value| { + let coordinates = u64::try_from(value.logistic_normal_coordinates.len()) + .map_err(|_| AnalysisEngineError::LimitExceeded)?; + total + .checked_add(coordinates) + .ok_or(AnalysisEngineError::LimitExceeded) + })?; let summary = AnalysisResultSummary { analysis_family: "topic_context_posterior".into(), evidence_count: document_count, statistic_count, - validation_status: TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS.into(), + validation_status: "validated".into(), }; let terminal_result = AnalysisRunTerminalResult::succeeded( request, accepted, - format!("topic_context_posterior_artifact_{}", &digest[..16]), - digest, + format!("topic_context_posterior_artifact_{}", &artifact_digest[..16]), + artifact_digest, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, completed_at, summary, @@ -759,693 +209,3 @@ pub fn execute_topic_context_posterior_run( terminal_result, }) } - -#[cfg(test)] -mod tests { - use super::AnalysisEngineError; - use super::{ - ENTRY_LIMIT, TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, - TopicActivityInterval, TopicContextMembership, TopicContextPosteriorArtifact, - TopicDocumentRelation, TopicLineageEvent, TopicPostPlausibleValue, within_entry_limits, - }; - - macro_rules! invalid { - ($change:expr) => {{ - let mut candidate = artifact(); - $change(&mut candidate); - assert!(candidate.to_json().is_err()); - }}; - } - - fn artifact() -> TopicContextPosteriorArtifact { - let documents = [ - "018f3f7a-7b7c-7d00-8000-000000000001", - "018f3f7a-7b7c-7d00-8000-000000000002", - ]; - TopicContextPosteriorArtifact { - schema_version: TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION.into(), - run_id: "run-1".into(), - snapshot_id: "snapshot-1".into(), - source_snapshot_sha256: "0".repeat(64), - knowledge_cutoff: "2026-08-01T00:00:00Z".into(), - event_clock_code: "event_time_rfc3339".into(), - model_contract_version: "trsl-tm-v1".into(), - posterior_draw_set_id: "draw-set-1".into(), - posterior_draw_count: 2, - topic_count: 2, - topic_ids: vec![ - "018f3f7a-7b7c-7d00-8000-000000000101".into(), - "018f3f7a-7b7c-7d00-8000-000000000102".into(), - ], - activity_intervals: [ - "018f3f7a-7b7c-7d00-8000-000000000101", - "018f3f7a-7b7c-7d00-8000-000000000102", - ] - .map(|topic_id| TopicActivityInterval { - topic_id: topic_id.into(), - state_code: "active".into(), - valid_from: "2026-07-01T00:00:00Z".into(), - valid_to: "2026-07-15T00:00:00Z".into(), - }) - .into(), - lineage_events: vec![], - document_relations: vec![TopicDocumentRelation { - source_document_id: documents[0].into(), - target_document_id: documents[1].into(), - relation_kind_code: "event_lineage_precedes".into(), - event_time: "2026-07-15T00:00:00Z".into(), - evidence_sha256: "c".repeat(64), - evidence_resource_id: "evidence-relation-1".into(), - provenance_assertion_id: "provenance-relation-1".into(), - }], - plausible_values: documents - .iter() - .flat_map(|document| { - (0..2).map(|draw| TopicPostPlausibleValue { - document_id: (*document).into(), - draw_index: draw, - event_time: "2026-07-15T00:00:00Z".into(), - logistic_normal_coordinates: vec![if draw == 0 { 0.0 } else { 0.1 }], - }) - }) - .collect(), - memberships: documents - .iter() - .flat_map(|document| { - ["business_unit", "process_unit", "team", "person"].map(|dimension| { - TopicContextMembership { - document_id: (*document).into(), - dimension_code: dimension.into(), - context_id: format!("{dimension}-{document}"), - weight: 1.0, - valid_from: "2026-07-01T00:00:00Z".into(), - valid_to: "2026-08-01T00:00:00Z".into(), - evidence_sha256: "b".repeat(64), - evidence_resource_id: format!("evidence-{dimension}-{document}"), - provenance_assertion_id: format!("provenance-{dimension}-{document}"), - } - }) - }) - .collect(), - inference_status: "posterior_topic_coordinates_not_importance".into(), - } - } - - #[test] - fn round_trip_preserves_plausible_values() { - let artifact = artifact(); - let json = artifact.to_json().expect("json"); - let parsed = TopicContextPosteriorArtifact::from_json(&json).expect("parse"); - assert_eq!(parsed.to_json().expect("canonical"), json); - assert_eq!(artifact.sha256().expect("digest").len(), 64); - } - - #[test] - fn rejects_missing_draw_instead_of_collapsing_uncertainty() { - let mut incomplete = artifact(); - incomplete.plausible_values.pop(); - assert!(incomplete.to_json().is_err()); - - let mut duplicate = artifact(); - duplicate - .plausible_values - .push(duplicate.plausible_values[0].clone()); - assert!(duplicate.to_json().is_err()); - - let mut reordered = artifact(); - reordered.plausible_values.swap(0, 1); - assert_eq!(reordered.to_json(), artifact().to_json()); - assert_eq!(reordered.sha256(), artifact().sha256()); - - let mut distinct_evidence = artifact(); - let mut relation = distinct_evidence.document_relations[0].clone(); - relation.evidence_resource_id = "evidence-relation-0".into(); - distinct_evidence.document_relations.push(relation); - let lineage = TopicLineageEvent { - event_code: "split".into(), - source_topic_id: distinct_evidence.topic_ids[0].clone(), - target_topic_id: Some(distinct_evidence.topic_ids[1].clone()), - event_time: "2026-07-15T00:00:00Z".into(), - evidence_sha256: "d".repeat(64), - evidence_resource_id: "evidence-lineage-1".into(), - provenance_assertion_id: "provenance-lineage-canonical".into(), - }; - distinct_evidence.lineage_events.extend([ - lineage.clone(), - TopicLineageEvent { - evidence_resource_id: "evidence-lineage-0".into(), - ..lineage - }, - ]); - let canonical_json = distinct_evidence.to_json(); - distinct_evidence.document_relations.swap(0, 1); - distinct_evidence.lineage_events.swap(0, 1); - assert_eq!(distinct_evidence.to_json(), canonical_json); - } - - #[test] - fn compares_absolute_instants_and_requires_membership_coverage() { - let mut equivalent_offsets = artifact(); - equivalent_offsets.activity_intervals[0].valid_from = "2026-07-01T09:00:00+09:00".into(); - equivalent_offsets.activity_intervals[0].valid_to = "2026-07-01T00:00:00Z".into(); - assert!(equivalent_offsets.to_json().is_err()); - - let mut reversed = artifact(); - reversed.activity_intervals[0].valid_from = "2026-07-02T00:00:00Z".into(); - reversed.activity_intervals[0].valid_to = "2026-07-01T23:00:00Z".into(); - assert!(reversed.to_json().is_err()); - - let mut uncovered = artifact(); - uncovered.memberships[0].valid_to = "2026-07-14T23:59:59Z".into(); - assert!(uncovered.to_json().is_err()); - } - - #[test] - fn rejects_every_foreign_header_and_bound() { - invalid!(|value: &mut TopicContextPosteriorArtifact| value.schema_version.clear()); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.run_id.clear()); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.snapshot_id.clear()); - invalid!(|value: &mut TopicContextPosteriorArtifact| value - .source_snapshot_sha256 - .replace_range(..1, "A")); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.knowledge_cutoff.clear()); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.event_clock_code.clear()); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.model_contract_version.clear()); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.posterior_draw_set_id.clear()); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.posterior_draw_count = 0); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.topic_count = 1); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.topic_ids.pop()); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.topic_ids[0].clear()); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.topic_ids[1] = - value.topic_ids[0].clone() - ); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.inference_status.clear()); - assert!(within_entry_limits([ENTRY_LIMIT; 5], ENTRY_LIMIT)); - assert!(!within_entry_limits( - [ENTRY_LIMIT + 1, 0, 0, 0, 0], - ENTRY_LIMIT - )); - assert!(!artifact().has_valid_header_with_entry_limit(0)); - assert!(TopicContextPosteriorArtifact::from_json("{").is_err()); - assert!( - TopicContextPosteriorArtifact::from_json( - &"x".repeat(TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT + 1) - ) - .is_err() - ); - } - - #[test] - fn rejects_invalid_activity_and_lineage_records() { - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.activity_intervals[0].topic_id = - "018f3f7a-7b7c-7d00-8000-000000000999".into() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.activity_intervals[0] - .state_code - .clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.activity_intervals[0] - .valid_from - .clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.activity_intervals[0].valid_to = - "2026-08-01T00:00:01Z".into() - ); - - let event = TopicLineageEvent { - event_code: "birth".into(), - source_topic_id: "018f3f7a-7b7c-7d00-8000-000000000101".into(), - target_topic_id: None, - event_time: "2026-07-15T00:00:00Z".into(), - evidence_sha256: "c".repeat(64), - evidence_resource_id: "evidence-lineage-1".into(), - provenance_assertion_id: "provenance-lineage-1".into(), - }; - let mut valid = artifact(); - valid.lineage_events.push(event.clone()); - assert!(valid.to_json().is_ok()); - invalid!(|value: &mut TopicContextPosteriorArtifact| { - value.lineage_events.push(event.clone()); - value.lineage_events[0].event_code.clear(); - }); - invalid!(|value: &mut TopicContextPosteriorArtifact| { - value.lineage_events.push(event.clone()); - value.lineage_events[0].source_topic_id.clear(); - }); - invalid!(|value: &mut TopicContextPosteriorArtifact| { - value.lineage_events.push(event.clone()); - value.lineage_events[0].target_topic_id = Some("missing-topic".into()); - }); - invalid!(|value: &mut TopicContextPosteriorArtifact| { - value.lineage_events.push(event.clone()); - value.lineage_events[0].event_time.clear(); - }); - invalid!(|value: &mut TopicContextPosteriorArtifact| { - value.lineage_events.push(event.clone()); - value.lineage_events[0].evidence_sha256.clear(); - }); - invalid!(|value: &mut TopicContextPosteriorArtifact| { - value.lineage_events.push(event.clone()); - value.lineage_events[0].evidence_resource_id.clear(); - }); - invalid!(|value: &mut TopicContextPosteriorArtifact| { - value.lineage_events.push(event.clone()); - value.lineage_events[0].provenance_assertion_id.clear(); - }); - invalid!(|value: &mut TopicContextPosteriorArtifact| { - value.lineage_events.push(event.clone()); - value.lineage_events[0].event_time = "2026-08-01T00:00:01Z".into(); - }); - invalid!(|value: &mut TopicContextPosteriorArtifact| { - value.lineage_events.push(event.clone()); - value.lineage_events.push(event.clone()); - }); - - let mut overlap = artifact(); - let mut non_overlapping = artifact(); - let topic_id = non_overlapping.activity_intervals[0].topic_id.clone(); - non_overlapping.activity_intervals.extend([ - TopicActivityInterval { - topic_id: topic_id.clone(), - state_code: "dormant".into(), - valid_from: "2026-07-15T00:00:01Z".into(), - valid_to: "2026-07-20T00:00:00Z".into(), - }, - TopicActivityInterval { - topic_id, - state_code: "reactivated".into(), - valid_from: "2026-07-20T00:00:01Z".into(), - valid_to: "2026-08-01T00:00:00Z".into(), - }, - ]); - assert!(non_overlapping.to_json().is_ok()); - let canonical_json = non_overlapping.to_json(); - non_overlapping.activity_intervals.swap(0, 1); - assert_eq!(non_overlapping.to_json(), canonical_json); - overlap.activity_intervals.push(TopicActivityInterval { - topic_id: overlap.activity_intervals[0].topic_id.clone(), - state_code: "dormant".into(), - valid_from: "2026-07-15T00:00:00Z".into(), - valid_to: "2026-07-15T00:00:00Z".into(), - }); - assert!(overlap.to_json().is_err()); - } - - #[test] - fn rejects_incomplete_topic_state_and_lineage_shapes() { - invalid!(|value: &mut TopicContextPosteriorArtifact| value - .activity_intervals - .push(value.activity_intervals[0].clone())); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.activity_intervals[0].state_code = - "reactivated".into() - ); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.activity_intervals.clear()); - invalid!(|value: &mut TopicContextPosteriorArtifact| { - value.activity_intervals.push(TopicActivityInterval { - topic_id: value.activity_intervals[0].topic_id.clone(), - state_code: "active".into(), - valid_from: "2026-07-15T00:00:01Z".into(), - valid_to: "2026-07-16T00:00:00Z".into(), - }); - }); - let source_topic_id = artifact().topic_ids[0].clone(); - for (event_code, target_topic_id) in - [("split", None), ("merge", Some(source_topic_id.clone()))] - { - invalid!(|value: &mut TopicContextPosteriorArtifact| { - value.lineage_events.push(TopicLineageEvent { - event_code: event_code.into(), - source_topic_id: source_topic_id.clone(), - target_topic_id: target_topic_id.clone(), - event_time: "2026-07-15T00:00:00Z".into(), - evidence_sha256: "c".repeat(64), - evidence_resource_id: "evidence-lineage-shape".into(), - provenance_assertion_id: "provenance-lineage-shape".into(), - }); - }); - } - } - - #[test] - fn rejects_invalid_posterior_records() { - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0] - .document_id - .clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0] - .event_time - .clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0].draw_index = 2 - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0] - .logistic_normal_coordinates - .clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0] - .logistic_normal_coordinates[0] = - f64::NAN - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.plausible_values[1].event_time = - "2026-07-16T00:00:00Z".into() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0].event_time = - "2026-08-01T00:00:01Z".into() - ); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.plausible_values.truncate(2)); - } - - #[test] - fn rejects_invalid_membership_and_relation_records() { - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0].document_id.clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0].dimension_code.clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0].context_id.clear() - ); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.memberships[0].weight = 0.0); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0].weight = f64::NAN - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0].valid_from.clear() - ); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.memberships[0].valid_to.clear()); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0].valid_from = - "2026-07-16T00:00:00Z".into() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0].valid_from = - "2026-08-02T00:00:00Z".into() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0] - .evidence_sha256 - .clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0] - .evidence_resource_id - .clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0] - .provenance_assertion_id - .clear() - ); - invalid!(|value: &mut TopicContextPosteriorArtifact| value - .memberships - .push(value.memberships[0].clone())); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.memberships.remove(0)); - let mut reordered = artifact(); - reordered.memberships.swap(0, 1); - assert_eq!(reordered.to_json(), artifact().to_json()); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0].document_id = - "018f3f7a-7b7c-7d00-8000-000000000003".into() - ); - - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] - .target_document_id = - value.document_relations[0].source_document_id.clone() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] - .source_document_id = - "018f3f7a-7b7c-7d00-8000-000000000003".into() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] - .target_document_id = - "018f3f7a-7b7c-7d00-8000-000000000003".into() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] - .relation_kind_code - .clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.document_relations[0].event_time = - "2026-08-01T00:00:01Z".into() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] - .evidence_sha256 - .clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] - .evidence_resource_id - .clear() - ); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] - .provenance_assertion_id - .clear() - ); - invalid!(|value: &mut TopicContextPosteriorArtifact| value - .document_relations - .push(value.document_relations[0].clone())); - invalid!(|value: &mut TopicContextPosteriorArtifact| value.memberships.truncate(4)); - } - - #[test] - fn rejects_ambiguous_relation_semantics_and_provenance() { - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] - .relation_kind_code = - "associated_with".into() - ); - invalid!(|value: &mut TopicContextPosteriorArtifact| { - for plausible_value in value - .plausible_values - .iter_mut() - .filter(|item| item.document_id.ends_with("0001")) - { - plausible_value.event_time = "2026-07-16T00:00:00Z".into(); - } - }); - invalid!( - |value: &mut TopicContextPosteriorArtifact| value.memberships[0] - .provenance_assertion_id = - value.document_relations[0].provenance_assertion_id.clone() - ); - - let mut relation_time_reuse = artifact(); - let mut relation = relation_time_reuse.document_relations[0].clone(); - relation.event_time = "2026-07-14T00:00:00Z".into(); - relation_time_reuse.document_relations.push(relation); - assert!(relation_time_reuse.to_json().is_err()); - - let mut lineage_time_reuse = artifact(); - let lineage = TopicLineageEvent { - event_code: "birth".into(), - source_topic_id: lineage_time_reuse.topic_ids[0].clone(), - target_topic_id: None, - event_time: "2026-07-15T00:00:00Z".into(), - evidence_sha256: "c".repeat(64), - evidence_resource_id: "evidence-lineage-time".into(), - provenance_assertion_id: "provenance-lineage-time".into(), - }; - lineage_time_reuse.lineage_events.push(lineage.clone()); - lineage_time_reuse.lineage_events.push(TopicLineageEvent { - event_time: "2026-07-14T00:00:00Z".into(), - ..lineage - }); - assert!(lineage_time_reuse.to_json().is_err()); - - let mut membership_window_reuse = artifact(); - let mut membership = membership_window_reuse.memberships[0].clone(); - membership.valid_from = "2026-06-30T00:00:00Z".into(); - membership_window_reuse.memberships.push(membership); - assert!(membership_window_reuse.to_json().is_err()); - } - - fn append_synthetic_document( - artifact: &mut TopicContextPosteriorArtifact, - document: &str, - context_id: &str, - ) { - for draw in 0..artifact.posterior_draw_count { - artifact.plausible_values.push(TopicPostPlausibleValue { - document_id: document.to_string(), - draw_index: draw, - event_time: "2026-07-15T00:00:00Z".into(), - logistic_normal_coordinates: vec![0.0], - }); - } - for dimension in ["business_unit", "process_unit", "team", "person"] { - artifact.memberships.push(TopicContextMembership { - document_id: document.to_string(), - dimension_code: dimension.into(), - context_id: context_id.to_string(), - weight: 1.0, - valid_from: "2026-07-01T00:00:00Z".into(), - valid_to: "2026-08-01T00:00:00Z".into(), - evidence_sha256: "b".repeat(64), - evidence_resource_id: format!("evidence-{dimension}-{document}"), - provenance_assertion_id: format!("provenance-{dimension}-{document}"), - }); - } - } - - #[test] - fn to_json_refuses_payloads_over_the_canonical_byte_limit() { - // Grow the artifact with fully valid synthetic documents — each with - // the full draw set and all four membership dimensions — until the - // canonical serialization crosses 16 MiB, so the size branch (and - // only the size branch) refuses with LimitExceeded. The count is - // derived from one measured per-document delta, so the loop runs - // once instead of re-serializing the whole payload per step. - let probe = artifact(); - let per_document_bytes = { - let mut one = probe.clone(); - let document = - ::uuid::Uuid::from_u128(0x8000_0000_0000_0000_0000_0000_0000_0000_u128).to_string(); - append_synthetic_document(&mut one, &document, "context-probe"); - serde_json::to_string(&one).expect("probe").len() - - serde_json::to_string(&probe).expect("base").len() - }; - assert!(per_document_bytes > 0); - let extra_documents = TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT / per_document_bytes + 2; - let entries_per_document = - usize::try_from(probe.posterior_draw_count + 4).expect("bounded draw count fits usize"); - let projected_entries = extra_documents * entries_per_document; - assert!( - projected_entries <= ENTRY_LIMIT, - "derived documents must stay inside the entry cap" - ); - let mut oversized = probe; - // Bulk-fill up to the estimate, then top up one document at a time - // (near the limit, only a handful of iterations remain) so rounding - // differences between the probe and the real entries cannot leave the - // payload under the threshold. - for index in 0..extra_documents { - let document = ::uuid::Uuid::from_u128( - 0x8000_0000_0000_0000_0000_0000_0000_0000_u128 - + u128::try_from(index).expect("index fits u128"), - ) - .to_string(); - append_synthetic_document(&mut oversized, &document, &format!("context-{index}")); - } - let mut top_up = extra_documents; - while serde_json::to_string(&oversized) - .expect("canonical serialization") - .len() - <= TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT - { - assert!( - top_up < extra_documents + 1_000, - "byte limit not crossed within the top-up budget" - ); - let document = ::uuid::Uuid::from_u128( - 0x8000_0000_0000_0000_0000_0000_0000_0000_u128 - + u128::try_from(top_up).expect("fits"), - ) - .to_string(); - append_synthetic_document( - &mut oversized, - &document, - &format!("context-topup-{top_up}"), - ); - top_up += 1; - } - assert_eq!(oversized.to_json(), Err(AnalysisEngineError::LimitExceeded)); - } - - #[test] - fn canonicalises_out_of_order_lineage_and_relation_sorts() { - let mut value = artifact(); - let topic_a = value.topic_ids[0].clone(); - let topic_b = value.topic_ids[1].clone(); - - // birth/retirement require no target; split/merge may self-reference. - let event_later = TopicLineageEvent { - event_code: "retirement".into(), - source_topic_id: topic_b.clone(), - target_topic_id: None, - event_time: "2026-07-20T00:00:00Z".into(), - evidence_sha256: "b".repeat(64), - evidence_resource_id: "evidence-lineage-later".into(), - provenance_assertion_id: "provenance-lineage-later".into(), - }; - let event_earlier = TopicLineageEvent { - event_code: "birth".into(), - source_topic_id: topic_a.clone(), - target_topic_id: None, - event_time: "2026-07-10T00:00:00Z".into(), - evidence_sha256: "a".repeat(64), - evidence_resource_id: "evidence-lineage-earlier".into(), - provenance_assertion_id: "provenance-lineage-earlier".into(), - }; - - let relation_later = TopicDocumentRelation { - source_document_id: "018f3f7a-7b7c-7d00-8000-000000000002".into(), - target_document_id: "018f3f7a-7b7c-7d00-8000-000000000001".into(), - relation_kind_code: "event_lineage_precedes".into(), - event_time: "2026-07-20T00:00:00Z".into(), - evidence_sha256: "d".repeat(64), - evidence_resource_id: "evidence-relation-later".into(), - provenance_assertion_id: "provenance-relation-later".into(), - }; - let relation_earlier = TopicDocumentRelation { - source_document_id: "018f3f7a-7b7c-7d00-8000-000000000001".into(), - target_document_id: "018f3f7a-7b7c-7d00-8000-000000000002".into(), - relation_kind_code: "event_lineage_precedes".into(), - event_time: "2026-07-10T00:00:00Z".into(), - evidence_sha256: "c".repeat(64), - evidence_resource_id: "evidence-relation-earlier".into(), - provenance_assertion_id: "provenance-relation-earlier".into(), - }; - - value.lineage_events = vec![event_later, event_earlier]; - value.document_relations = vec![relation_later, relation_earlier]; - let json = value.to_json().expect("canonical serialisation"); - let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json"); - - let events = parsed["lineage_events"].as_array().expect("events array"); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["event_code"], "birth"); - assert_eq!(events[1]["event_code"], "retirement"); - - let relations = parsed["document_relations"].as_array().expect("relations"); - assert_eq!(relations.len(), 2); - assert_eq!( - relations[0]["source_document_id"], - "018f3f7a-7b7c-7d00-8000-000000000001" - ); - assert_eq!( - relations[1]["source_document_id"], - "018f3f7a-7b7c-7d00-8000-000000000002" - ); - - let round_tripped = TopicContextPosteriorArtifact::from_json(&json).expect("round-trip"); - assert_eq!( - round_tripped.lineage_events[0].event_code, "birth", - "canonical lineage order survives round-trip" - ); - assert_eq!( - round_tripped.document_relations[0].source_document_id, - "018f3f7a-7b7c-7d00-8000-000000000001", - "canonical relation order survives round-trip" - ); - } -} diff --git a/crates/analysis_engine/src/topic_context_posterior_base.rs b/crates/analysis_engine/src/topic_context_posterior_base.rs new file mode 100644 index 000000000..1297613e6 --- /dev/null +++ b/crates/analysis_engine/src/topic_context_posterior_base.rs @@ -0,0 +1,1451 @@ +//! Posterior TRSL-TM producer contract for downstream context influence. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use temporal_core::KnowledgeCutoff; +use uuid::Uuid; + +use tepp_api::{ + AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, +}; + +use crate::{AnalysisEngineError, format_digest, require_receipt_identity, valid_identifier}; + +/// Exact posterior artifact schema. +pub const TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION: &str = "tepp.topic_context_posterior.v1"; +/// Maximum canonical JSON size. +pub const TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT: usize = 16 * 1024 * 1024; +/// Model contract required by the topic-context posterior analysis-run path. +pub const TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION: &str = "topic_context_posterior_v1"; +/// Producer model contract accepted by the analysis-run profile. +pub const TOPIC_CONTEXT_POSTERIOR_PRODUCER_CONTRACT_VERSION: &str = "trsl-tm-v1"; +/// Analysis-run output profile required for a topic-context posterior artifact. +pub const TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE: &str = "topic_context_posterior_v1"; +const TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS: &str = "posterior_topic_coordinates_not_importance"; +const ENTRY_LIMIT: usize = 1_000_000; +const DIMENSIONS: [&str; 4] = ["business_unit", "process_unit", "team", "person"]; +type PosteriorDraws = BTreeMap>; +type DocumentEventTimes = BTreeMap; + +/// One explicit active, dormant, or reactivated interval for a global topic. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TopicActivityInterval { + /// Opaque stable topic identity. + pub topic_id: String, + /// Activity state: `active`, `dormant`, or `reactivated`. + pub state_code: String, + /// Inclusive event-time start. + pub valid_from: String, + /// Inclusive event-time end. + pub valid_to: String, +} + +/// One explicit topic birth/split/merge/retirement event, when fitted. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TopicLineageEvent { + /// Event kind supplied by TEPP, never inferred by a consumer. + pub event_code: String, + /// Source stable topic identity. + pub source_topic_id: String, + /// Optional target stable topic identity. + pub target_topic_id: Option, + /// Event time. + pub event_time: String, + /// Digest of event evidence. + pub evidence_sha256: String, + /// Opaque evidence resource identity. + pub evidence_resource_id: String, + /// Opaque provenance assertion identity. + pub provenance_assertion_id: String, +} + +/// One admitted Event Lineage or document-relation record. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TopicDocumentRelation { + /// Opaque source document identity. + pub source_document_id: String, + /// Opaque target document identity. + pub target_document_id: String, + /// Producer-owned closed relation kind. + pub relation_kind_code: String, + /// Event time at which the relation is admitted. + pub event_time: String, + /// Digest of relation evidence. + pub evidence_sha256: String, + /// Opaque evidence resource identity. + pub evidence_resource_id: String, + /// Opaque provenance assertion identity. + pub provenance_assertion_id: String, +} + +/// One document posterior plausible value for one draw. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TopicPostPlausibleValue { + /// Opaque document identity. + pub document_id: String, + /// Posterior draw index. + pub draw_index: u64, + /// Event time used by the model. + pub event_time: String, + /// Full-rank logistic-normal coordinates of length `topic_count - 1`. + pub logistic_normal_coordinates: Vec, +} + +/// One time-valid provenance-bound organizational membership. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TopicContextMembership { + /// Opaque document identity. + pub document_id: String, + /// `business_unit`, `process_unit`, `team`, or `person`. + pub dimension_code: String, + /// Opaque context identity within the dimension. + pub context_id: String, + /// Source-derived multiple-membership weight. + pub weight: f64, + /// Inclusive event-time validity start. + pub valid_from: String, + /// Inclusive event-time validity end. + pub valid_to: String, + /// Digest of membership source evidence. + pub evidence_sha256: String, + /// Opaque evidence resource identity. + pub evidence_resource_id: String, + /// Opaque provenance assertion identity. + pub provenance_assertion_id: String, +} + +/// Digest-bound posterior artifact consumed by fast-mlsirm. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TopicContextPosteriorArtifact { + /// Exact schema identity. + pub schema_version: String, + /// Opaque model-run identity. + pub run_id: String, + /// Immutable source snapshot identity. + pub snapshot_id: String, + /// Canonical source snapshot SHA-256. + pub source_snapshot_sha256: String, + /// Historical knowledge cutoff. + pub knowledge_cutoff: String, + /// Exact event-time clock represented by all temporal fields. + pub event_clock_code: String, + /// Exact model contract version. + pub model_contract_version: String, + /// Opaque posterior draw-set identity. + pub posterior_draw_set_id: String, + /// Number of draws present for every document. + pub posterior_draw_count: u64, + /// Number of global topics. + pub topic_count: u64, + /// Stable topic identities in logistic-normal coordinate order. + pub topic_ids: Vec, + /// Explicit topic-state intervals. + pub activity_intervals: Vec, + /// Explicit topic lineage events, when present. + pub lineage_events: Vec, + /// Complete admitted Event Lineage/document relations. + pub document_relations: Vec, + /// Complete document-by-draw posterior coordinates. + pub plausible_values: Vec, + /// Time-valid BU/PU/team/person memberships. + pub memberships: Vec, + /// Fixed interpretation boundary. + pub inference_status: String, +} + +/// Authoritative snapshot and cutoff-eligibility manifest for one artifact. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TopicContextPosteriorSnapshotManifest { + /// Immutable source snapshot identity. + pub snapshot_id: String, + /// Canonical digest of the resolved source snapshot bytes. + pub source_snapshot_sha256: String, + /// Historical cutoff applied while resolving eligibility. + pub knowledge_cutoff: String, + /// Canonical digest of the exact artifact admitted from this snapshot. + pub artifact_sha256: String, + /// Availability instant for every document represented by the artifact. + pub document_available_at: BTreeMap, +} + +fn digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn provenance_binding(fields: &[&[u8]]) -> String { + let mut digest = Sha256::new(); + for field in fields { + digest.update( + u64::try_from(field.len()) + .expect("bounded artifact field length fits u64") + .to_le_bytes(), + ); + digest.update(field); + } + format_digest(digest.finalize()) +} + +fn time(value: &str) -> Option { + KnowledgeCutoff::parse_rfc3339(value).ok() +} + +fn canonical_time(value: &str) -> Option { + time(value).filter(|instant| instant.to_rfc3339() == value) +} + +fn valid_activity_interval(interval: &TopicActivityInterval, topic_ids: &BTreeSet<&str>) -> bool { + topic_ids.contains(interval.topic_id.as_str()) + && ["active", "dormant", "reactivated"].contains(&interval.state_code.as_str()) + && canonical_time(&interval.valid_from) + .zip(canonical_time(&interval.valid_to)) + .is_some_and(|(valid_from, valid_to)| valid_from <= valid_to) +} + +fn within_entry_limits(lengths: [usize; 5], entry_limit: usize) -> bool { + lengths.into_iter().all(|length| length <= entry_limit) +} + +impl TopicContextPosteriorArtifact { + fn has_valid_header(&self) -> bool { + self.has_valid_header_with_entry_limit(ENTRY_LIMIT) + } + + fn has_valid_header_with_entry_limit(&self, entry_limit: usize) -> bool { + self.schema_version == TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION + && valid_identifier(&self.run_id) + && valid_identifier(&self.snapshot_id) + && digest(&self.source_snapshot_sha256) + && canonical_time(&self.knowledge_cutoff).is_some() + && self.event_clock_code == "event_time_rfc3339" + && valid_identifier(&self.model_contract_version) + && valid_identifier(&self.posterior_draw_set_id) + && self.posterior_draw_count > 0 + && self.topic_count >= 2 + && usize::try_from(self.topic_count) == Ok(self.topic_ids.len()) + && within_entry_limits( + [ + self.activity_intervals.len(), + self.lineage_events.len(), + self.document_relations.len(), + self.plausible_values.len(), + self.memberships.len(), + ], + entry_limit, + ) + && self.inference_status == TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS + } + + /// Parse and validate one bounded posterior artifact. + /// + /// # Errors + /// + /// Returns a size or evidence error for any foreign, incomplete, + /// non-finite, temporally invalid, or mixed-identity payload. + pub fn from_json(payload: &str) -> Result { + if payload.len() > TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + let artifact = + serde_json::from_str(payload).map_err(|_| AnalysisEngineError::InvalidEvidence)?; + Self::validate(&artifact)?; + Ok(artifact) + } + + /// Serialize canonical validated JSON. + /// + /// # Errors + /// + /// Returns a validation, serialization, or size error. + pub fn to_json(&self) -> Result { + self.validate()?; + let mut canonical = self.clone(); + canonical.activity_intervals.sort_by(|a, b| { + (&a.topic_id, &a.valid_from, &a.valid_to, &a.state_code).cmp(&( + &b.topic_id, + &b.valid_from, + &b.valid_to, + &b.state_code, + )) + }); + canonical.lineage_events.sort_by(|a, b| { + ( + &a.event_code, + &a.source_topic_id, + &a.target_topic_id, + &a.event_time, + &a.evidence_resource_id, + &a.provenance_assertion_id, + ) + .cmp(&( + &b.event_code, + &b.source_topic_id, + &b.target_topic_id, + &b.event_time, + &b.evidence_resource_id, + &b.provenance_assertion_id, + )) + }); + canonical.document_relations.sort_by(|a, b| { + ( + &a.source_document_id, + &a.target_document_id, + &a.relation_kind_code, + &a.event_time, + &a.evidence_resource_id, + &a.provenance_assertion_id, + ) + .cmp(&( + &b.source_document_id, + &b.target_document_id, + &b.relation_kind_code, + &b.event_time, + &b.evidence_resource_id, + &b.provenance_assertion_id, + )) + }); + canonical + .plausible_values + .sort_by(|a, b| (&a.document_id, a.draw_index).cmp(&(&b.document_id, b.draw_index))); + canonical.memberships.sort_by(|a, b| { + ( + &a.document_id, + &a.dimension_code, + &a.context_id, + &a.valid_from, + &a.valid_to, + ) + .cmp(&( + &b.document_id, + &b.dimension_code, + &b.context_id, + &b.valid_from, + &b.valid_to, + )) + }); + let payload = serde_json::to_string(&canonical) + .map_err(|_| AnalysisEngineError::SerializationFailure)?; + if payload.len() > TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT { + return Err(AnalysisEngineError::LimitExceeded); + } + Ok(payload) + } + + /// Return the canonical artifact SHA-256. + /// + /// # Errors + /// + /// Returns a validation or serialization error. + pub fn sha256(&self) -> Result { + self.to_json() + .map(|json| format_digest(Sha256::digest(json.into_bytes()))) + } + + fn validate(&self) -> Result<(), AnalysisEngineError> { + if !self.has_valid_header() { + return Err(AnalysisEngineError::InvalidEvidence); + } + let cutoff = + canonical_time(&self.knowledge_cutoff).ok_or(AnalysisEngineError::InvalidEvidence)?; + let topic_ids: BTreeSet<&str> = self.topic_ids.iter().map(String::as_str).collect(); + if topic_ids.len() != self.topic_ids.len() + || self + .topic_ids + .iter() + .any(|topic_id| Uuid::parse_str(topic_id).is_err()) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + self.validate_topic_records(cutoff, &topic_ids)?; + let (draws, event_times) = self.validate_plausible_values(cutoff)?; + self.validate_document_relations(cutoff, &draws, &event_times)?; + self.validate_memberships(&draws, &event_times)?; + self.validate_provenance_bindings() + } + + fn validate_provenance_bindings(&self) -> Result<(), AnalysisEngineError> { + let mut bindings = BTreeMap::new(); + let mut bind = |id: &str, value: String| { + if bindings + .insert(id.to_owned(), value.clone()) + .is_some_and(|existing| existing != value) + { + Err(AnalysisEngineError::InvalidEvidence) + } else { + Ok(()) + } + }; + for event in &self.lineage_events { + let fields: [&[u8]; 8] = [ + b"topic", + event.event_code.as_bytes(), + event.source_topic_id.as_bytes(), + event.target_topic_id.as_deref().unwrap_or("").as_bytes(), + event.event_time.as_bytes(), + event.evidence_resource_id.as_bytes(), + event.evidence_sha256.as_bytes(), + self.source_snapshot_sha256.as_bytes(), + ]; + bind(&event.provenance_assertion_id, provenance_binding(&fields))?; + } + for relation in &self.document_relations { + let fields: [&[u8]; 8] = [ + b"document", + relation.relation_kind_code.as_bytes(), + relation.source_document_id.as_bytes(), + relation.target_document_id.as_bytes(), + relation.event_time.as_bytes(), + relation.evidence_resource_id.as_bytes(), + relation.evidence_sha256.as_bytes(), + self.source_snapshot_sha256.as_bytes(), + ]; + bind( + &relation.provenance_assertion_id, + provenance_binding(&fields), + )?; + } + for membership in &self.memberships { + let weight = membership.weight.to_bits().to_le_bytes(); + let fields: [&[u8]; 10] = [ + b"membership", + membership.dimension_code.as_bytes(), + membership.document_id.as_bytes(), + membership.context_id.as_bytes(), + &weight, + membership.valid_from.as_bytes(), + membership.valid_to.as_bytes(), + membership.evidence_resource_id.as_bytes(), + membership.evidence_sha256.as_bytes(), + self.source_snapshot_sha256.as_bytes(), + ]; + bind( + &membership.provenance_assertion_id, + provenance_binding(&fields), + )?; + } + Ok(()) + } + + fn validate_topic_records( + &self, + cutoff: KnowledgeCutoff, + topic_ids: &BTreeSet<&str>, + ) -> Result<(), AnalysisEngineError> { + let mut activity_by_topic: BTreeMap<&str, Vec<(KnowledgeCutoff, KnowledgeCutoff, &str)>> = + BTreeMap::new(); + let mut seen_activity = BTreeSet::new(); + for interval in &self.activity_intervals { + if !valid_activity_interval(interval, topic_ids) { + return Err(AnalysisEngineError::InvalidEvidence); + } + let valid_from = + canonical_time(&interval.valid_from).ok_or(AnalysisEngineError::InvalidEvidence)?; + let valid_to = + canonical_time(&interval.valid_to).ok_or(AnalysisEngineError::InvalidEvidence)?; + if valid_to > cutoff { + return Err(AnalysisEngineError::InvalidEvidence); + } + let key = ( + interval.topic_id.as_str(), + valid_from, + valid_to, + interval.state_code.as_str(), + ); + if !seen_activity.insert(key) { + return Err(AnalysisEngineError::InvalidEvidence); + } + activity_by_topic + .entry(&interval.topic_id) + .or_default() + .push((valid_from, valid_to, interval.state_code.as_str())); + } + if activity_by_topic.len() != topic_ids.len() { + return Err(AnalysisEngineError::InvalidEvidence); + } + for intervals in activity_by_topic.values_mut() { + intervals.sort(); + if intervals.first().map(|interval| interval.2) != Some("active") { + return Err(AnalysisEngineError::InvalidEvidence); + } + for pair in intervals.windows(2) { + let valid_transition = matches!( + (pair[0].2, pair[1].2), + ("active" | "reactivated", "dormant") | ("dormant", "reactivated") + ); + if pair[1].0 <= pair[0].1 || !valid_transition { + return Err(AnalysisEngineError::InvalidEvidence); + } + } + } + let mut seen_lineage = BTreeSet::new(); + for event in &self.lineage_events { + let event_time = + canonical_time(&event.event_time).ok_or(AnalysisEngineError::InvalidEvidence)?; + let key = ( + event.event_code.as_str(), + event.source_topic_id.as_str(), + event.target_topic_id.as_deref(), + event_time, + event.evidence_resource_id.as_str(), + event.provenance_assertion_id.as_str(), + ); + if !["birth", "split", "merge", "retirement"].contains(&event.event_code.as_str()) + || !topic_ids.contains(event.source_topic_id.as_str()) + || event + .target_topic_id + .as_deref() + .is_some_and(|target| !topic_ids.contains(target)) + || match event.event_code.as_str() { + "birth" | "retirement" => event.target_topic_id.is_some(), + // Only "split"/"merge" remain; their target must be None + // or self-referencing. + _ => event + .target_topic_id + .as_deref() + .is_none_or(|target| target == event.source_topic_id), + } + || event_time > cutoff + || !digest(&event.evidence_sha256) + || !valid_identifier(&event.evidence_resource_id) + || !valid_identifier(&event.provenance_assertion_id) + || !seen_lineage.insert(key) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + } + Ok(()) + } + + fn validate_plausible_values( + &self, + cutoff: KnowledgeCutoff, + ) -> Result<(PosteriorDraws, DocumentEventTimes), AnalysisEngineError> { + let mut draws: PosteriorDraws = BTreeMap::new(); + let mut event_times = BTreeMap::new(); + for value in &self.plausible_values { + let document = Uuid::parse_str(&value.document_id) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + let event_time = + canonical_time(&value.event_time).ok_or(AnalysisEngineError::InvalidEvidence)?; + if value.draw_index >= self.posterior_draw_count + || event_time > cutoff + || value.logistic_normal_coordinates.len() + != usize::try_from(self.topic_count - 1) + .map_err(|_| AnalysisEngineError::InvalidEvidence)? + || value + .logistic_normal_coordinates + .iter() + .any(|coordinate| !coordinate.is_finite()) + || !draws.entry(document).or_default().insert(value.draw_index) + || event_times + .insert(document, event_time) + .is_some_and(|previous| previous != event_time) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + } + if draws.len() < 2 + || draws + .values() + .any(|indices| usize::try_from(self.posterior_draw_count) != Ok(indices.len())) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok((draws, event_times)) + } + + fn validate_document_relations( + &self, + cutoff: KnowledgeCutoff, + draws: &PosteriorDraws, + event_times: &DocumentEventTimes, + ) -> Result<(), AnalysisEngineError> { + let mut seen_relations = BTreeSet::new(); + for relation in &self.document_relations { + let source = Uuid::parse_str(&relation.source_document_id) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + let target = Uuid::parse_str(&relation.target_document_id) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + let event_time = + canonical_time(&relation.event_time).ok_or(AnalysisEngineError::InvalidEvidence)?; + let key = ( + source, + target, + relation.relation_kind_code.as_str(), + event_time, + relation.evidence_resource_id.as_str(), + relation.provenance_assertion_id.as_str(), + ); + if source == target + || !draws.contains_key(&source) + || !draws.contains_key(&target) + || relation.relation_kind_code != "event_lineage_precedes" + || event_times.get(&source) > event_times.get(&target) + || event_time > cutoff + || !digest(&relation.evidence_sha256) + || !valid_identifier(&relation.evidence_resource_id) + || !valid_identifier(&relation.provenance_assertion_id) + || !seen_relations.insert(key) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + } + Ok(()) + } + + fn validate_memberships( + &self, + draws: &PosteriorDraws, + event_times: &DocumentEventTimes, + ) -> Result<(), AnalysisEngineError> { + let mut dimensions: BTreeMap> = BTreeMap::new(); + let mut seen_memberships = BTreeSet::new(); + for membership in &self.memberships { + let document = Uuid::parse_str(&membership.document_id) + .map_err(|_| AnalysisEngineError::InvalidEvidence)?; + let valid_from = canonical_time(&membership.valid_from) + .ok_or(AnalysisEngineError::InvalidEvidence)?; + let valid_to = + canonical_time(&membership.valid_to).ok_or(AnalysisEngineError::InvalidEvidence)?; + let document_event_time = event_times + .get(&document) + .ok_or(AnalysisEngineError::InvalidEvidence)?; + let dimension_ordinal = DIMENSIONS + .iter() + .position(|dimension| *dimension == membership.dimension_code) + .ok_or(AnalysisEngineError::InvalidEvidence)?; + let key = ( + document, + dimension_ordinal, + membership.context_id.as_str(), + valid_from, + valid_to, + ); + if !valid_identifier(&membership.context_id) + || !membership.weight.is_finite() + || membership.weight <= 0.0 + || valid_from > valid_to + || *document_event_time < valid_from + || *document_event_time > valid_to + || !digest(&membership.evidence_sha256) + || !valid_identifier(&membership.evidence_resource_id) + || !valid_identifier(&membership.provenance_assertion_id) + || !seen_memberships.insert(key) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + dimensions + .entry(document) + .or_default() + .insert(membership.dimension_code.as_str()); + } + if dimensions.len() != draws.len() + || dimensions.values().any(|present| { + DIMENSIONS + .iter() + .any(|required| !present.contains(required)) + }) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + Ok(()) + } +} + +/// One completed topic-context posterior artifact and its terminal result. +#[derive(Clone, Debug, PartialEq)] +pub struct TopicContextPosteriorExecution { + /// Digest-bound producer posterior artifact. + pub artifact: TopicContextPosteriorArtifact, + /// Terminal result carrying the artifact identity, digest, and schema. + pub terminal_result: AnalysisRunTerminalResult, +} + +/// Execute posterior topic-context validation as one analysis-run profile. +/// +/// The executor validates an already-constructed +/// [`TopicContextPosteriorArtifact`] through its producer contract and does +/// not reimplement TRSL-TM fitting, collapse missing draws, infer topic +/// importance, or invent birth/split/merge events. Lineage events remain +/// producer-supplied. This is not a Bayesian sampler and not GPU execution. +/// +/// # Errors +/// +/// Returns a request/receipt/snapshot/cutoff/profile error or a producer +/// contract refusal. +pub fn execute_topic_context_posterior_run( + request: &AnalysisRunRequest, + accepted: &AnalysisRunAccepted, + manifest: &TopicContextPosteriorSnapshotManifest, + artifact: &TopicContextPosteriorArtifact, + completed_at: impl Into, +) -> Result { + request.to_json()?; + accepted.to_json()?; + require_receipt_identity(request, accepted)?; + if request.snapshot_id != manifest.snapshot_id || artifact.snapshot_id != manifest.snapshot_id { + return Err(AnalysisEngineError::SnapshotMismatch); + } + let knowledge_cutoff = + canonical_time(&manifest.knowledge_cutoff).ok_or(AnalysisEngineError::InvalidEvidence)?; + if request.knowledge_cutoff != manifest.knowledge_cutoff + || artifact.knowledge_cutoff != manifest.knowledge_cutoff + || artifact.source_snapshot_sha256 != manifest.source_snapshot_sha256 + || request.model_contract_version != TOPIC_CONTEXT_POSTERIOR_MODEL_CONTRACT_VERSION + || request.output_profile != TOPIC_CONTEXT_POSTERIOR_OUTPUT_PROFILE + || artifact.model_contract_version != TOPIC_CONTEXT_POSTERIOR_PRODUCER_CONTRACT_VERSION + || artifact.run_id != accepted.run_id + || artifact.inference_status != TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS + || !digest(&manifest.source_snapshot_sha256) + || !digest(&manifest.artifact_sha256) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + + let digest = artifact.sha256()?; + if digest != manifest.artifact_sha256 { + return Err(AnalysisEngineError::InvalidEvidence); + } + let mut document_ids = BTreeSet::new(); + for value in &artifact.plausible_values { + document_ids.insert(value.document_id.clone()); + } + if document_ids.len() != manifest.document_available_at.len() + || document_ids.iter().any(|document_id| { + manifest + .document_available_at + .get(document_id) + .and_then(|available_at| canonical_time(available_at)) + .is_none_or(|available_at| available_at > knowledge_cutoff) + }) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + // Artifact validation bounds the canonical payload to 16 MiB, so both + // counts are far below the public summary limit and fit in u64. + let document_count = document_ids.len() as u64; + let statistic_count = artifact + .plausible_values + .iter() + .map(|value| value.logistic_normal_coordinates.len() as u64) + .sum(); + let summary = AnalysisResultSummary { + analysis_family: "topic_context_posterior".into(), + evidence_count: document_count, + statistic_count, + validation_status: TOPIC_CONTEXT_POSTERIOR_INFERENCE_STATUS.into(), + }; + let terminal_result = AnalysisRunTerminalResult::succeeded( + request, + accepted, + format!("topic_context_posterior_artifact_{}", &digest[..16]), + digest, + TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, + completed_at, + summary, + )?; + Ok(TopicContextPosteriorExecution { + artifact: artifact.clone(), + terminal_result, + }) +} + +#[cfg(test)] +mod tests { + use super::AnalysisEngineError; + use super::{ + ENTRY_LIMIT, TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, + TopicActivityInterval, TopicContextMembership, TopicContextPosteriorArtifact, + TopicDocumentRelation, TopicLineageEvent, TopicPostPlausibleValue, within_entry_limits, + }; + + macro_rules! invalid { + ($change:expr) => {{ + let mut candidate = artifact(); + $change(&mut candidate); + assert!(candidate.to_json().is_err()); + }}; + } + + fn artifact() -> TopicContextPosteriorArtifact { + let documents = [ + "018f3f7a-7b7c-7d00-8000-000000000001", + "018f3f7a-7b7c-7d00-8000-000000000002", + ]; + TopicContextPosteriorArtifact { + schema_version: TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION.into(), + run_id: "run-1".into(), + snapshot_id: "snapshot-1".into(), + source_snapshot_sha256: "0".repeat(64), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + event_clock_code: "event_time_rfc3339".into(), + model_contract_version: "trsl-tm-v1".into(), + posterior_draw_set_id: "draw-set-1".into(), + posterior_draw_count: 2, + topic_count: 2, + topic_ids: vec![ + "018f3f7a-7b7c-7d00-8000-000000000101".into(), + "018f3f7a-7b7c-7d00-8000-000000000102".into(), + ], + activity_intervals: [ + "018f3f7a-7b7c-7d00-8000-000000000101", + "018f3f7a-7b7c-7d00-8000-000000000102", + ] + .map(|topic_id| TopicActivityInterval { + topic_id: topic_id.into(), + state_code: "active".into(), + valid_from: "2026-07-01T00:00:00Z".into(), + valid_to: "2026-07-15T00:00:00Z".into(), + }) + .into(), + lineage_events: vec![], + document_relations: vec![TopicDocumentRelation { + source_document_id: documents[0].into(), + target_document_id: documents[1].into(), + relation_kind_code: "event_lineage_precedes".into(), + event_time: "2026-07-15T00:00:00Z".into(), + evidence_sha256: "c".repeat(64), + evidence_resource_id: "evidence-relation-1".into(), + provenance_assertion_id: "provenance-relation-1".into(), + }], + plausible_values: documents + .iter() + .flat_map(|document| { + (0..2).map(|draw| TopicPostPlausibleValue { + document_id: (*document).into(), + draw_index: draw, + event_time: "2026-07-15T00:00:00Z".into(), + logistic_normal_coordinates: vec![if draw == 0 { 0.0 } else { 0.1 }], + }) + }) + .collect(), + memberships: documents + .iter() + .flat_map(|document| { + ["business_unit", "process_unit", "team", "person"].map(|dimension| { + TopicContextMembership { + document_id: (*document).into(), + dimension_code: dimension.into(), + context_id: format!("{dimension}-{document}"), + weight: 1.0, + valid_from: "2026-07-01T00:00:00Z".into(), + valid_to: "2026-08-01T00:00:00Z".into(), + evidence_sha256: "b".repeat(64), + evidence_resource_id: format!("evidence-{dimension}-{document}"), + provenance_assertion_id: format!("provenance-{dimension}-{document}"), + } + }) + }) + .collect(), + inference_status: "posterior_topic_coordinates_not_importance".into(), + } + } + + #[test] + fn round_trip_preserves_plausible_values() { + let artifact = artifact(); + let json = artifact.to_json().expect("json"); + let parsed = TopicContextPosteriorArtifact::from_json(&json).expect("parse"); + assert_eq!(parsed.to_json().expect("canonical"), json); + assert_eq!(artifact.sha256().expect("digest").len(), 64); + } + + #[test] + fn rejects_missing_draw_instead_of_collapsing_uncertainty() { + let mut incomplete = artifact(); + incomplete.plausible_values.pop(); + assert!(incomplete.to_json().is_err()); + + let mut duplicate = artifact(); + duplicate + .plausible_values + .push(duplicate.plausible_values[0].clone()); + assert!(duplicate.to_json().is_err()); + + let mut reordered = artifact(); + reordered.plausible_values.swap(0, 1); + assert_eq!(reordered.to_json(), artifact().to_json()); + assert_eq!(reordered.sha256(), artifact().sha256()); + + let mut distinct_evidence = artifact(); + let mut relation = distinct_evidence.document_relations[0].clone(); + relation.evidence_resource_id = "evidence-relation-0".into(); + distinct_evidence.document_relations.push(relation); + let lineage = TopicLineageEvent { + event_code: "split".into(), + source_topic_id: distinct_evidence.topic_ids[0].clone(), + target_topic_id: Some(distinct_evidence.topic_ids[1].clone()), + event_time: "2026-07-15T00:00:00Z".into(), + evidence_sha256: "d".repeat(64), + evidence_resource_id: "evidence-lineage-1".into(), + provenance_assertion_id: "provenance-lineage-canonical".into(), + }; + distinct_evidence.lineage_events.extend([ + lineage.clone(), + TopicLineageEvent { + evidence_resource_id: "evidence-lineage-0".into(), + ..lineage + }, + ]); + let canonical_json = distinct_evidence.to_json(); + distinct_evidence.document_relations.swap(0, 1); + distinct_evidence.lineage_events.swap(0, 1); + assert_eq!(distinct_evidence.to_json(), canonical_json); + } + + #[test] + fn compares_absolute_instants_and_requires_membership_coverage() { + let mut equivalent_offsets = artifact(); + equivalent_offsets.activity_intervals[0].valid_from = "2026-07-01T09:00:00+09:00".into(); + equivalent_offsets.activity_intervals[0].valid_to = "2026-07-01T00:00:00Z".into(); + assert!(equivalent_offsets.to_json().is_err()); + + let mut reversed = artifact(); + reversed.activity_intervals[0].valid_from = "2026-07-02T00:00:00Z".into(); + reversed.activity_intervals[0].valid_to = "2026-07-01T23:00:00Z".into(); + assert!(reversed.to_json().is_err()); + + let mut uncovered = artifact(); + uncovered.memberships[0].valid_to = "2026-07-14T23:59:59Z".into(); + assert!(uncovered.to_json().is_err()); + } + + #[test] + fn rejects_every_foreign_header_and_bound() { + invalid!(|value: &mut TopicContextPosteriorArtifact| value.schema_version.clear()); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.run_id.clear()); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.snapshot_id.clear()); + invalid!(|value: &mut TopicContextPosteriorArtifact| value + .source_snapshot_sha256 + .replace_range(..1, "A")); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.knowledge_cutoff.clear()); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.event_clock_code.clear()); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.model_contract_version.clear()); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.posterior_draw_set_id.clear()); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.posterior_draw_count = 0); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.topic_count = 1); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.topic_ids.pop()); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.topic_ids[0].clear()); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.topic_ids[1] = + value.topic_ids[0].clone() + ); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.inference_status.clear()); + assert!(within_entry_limits([ENTRY_LIMIT; 5], ENTRY_LIMIT)); + assert!(!within_entry_limits( + [ENTRY_LIMIT + 1, 0, 0, 0, 0], + ENTRY_LIMIT + )); + assert!(!artifact().has_valid_header_with_entry_limit(0)); + assert!(TopicContextPosteriorArtifact::from_json("{").is_err()); + assert!( + TopicContextPosteriorArtifact::from_json( + &"x".repeat(TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT + 1) + ) + .is_err() + ); + } + + #[test] + fn rejects_invalid_activity_and_lineage_records() { + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.activity_intervals[0].topic_id = + "018f3f7a-7b7c-7d00-8000-000000000999".into() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.activity_intervals[0] + .state_code + .clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.activity_intervals[0] + .valid_from + .clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.activity_intervals[0].valid_to = + "2026-08-01T00:00:01Z".into() + ); + + let event = TopicLineageEvent { + event_code: "birth".into(), + source_topic_id: "018f3f7a-7b7c-7d00-8000-000000000101".into(), + target_topic_id: None, + event_time: "2026-07-15T00:00:00Z".into(), + evidence_sha256: "c".repeat(64), + evidence_resource_id: "evidence-lineage-1".into(), + provenance_assertion_id: "provenance-lineage-1".into(), + }; + let mut valid = artifact(); + valid.lineage_events.push(event.clone()); + assert!(valid.to_json().is_ok()); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.lineage_events.push(event.clone()); + value.lineage_events[0].event_code.clear(); + }); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.lineage_events.push(event.clone()); + value.lineage_events[0].source_topic_id.clear(); + }); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.lineage_events.push(event.clone()); + value.lineage_events[0].target_topic_id = Some("missing-topic".into()); + }); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.lineage_events.push(event.clone()); + value.lineage_events[0].event_time.clear(); + }); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.lineage_events.push(event.clone()); + value.lineage_events[0].evidence_sha256.clear(); + }); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.lineage_events.push(event.clone()); + value.lineage_events[0].evidence_resource_id.clear(); + }); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.lineage_events.push(event.clone()); + value.lineage_events[0].provenance_assertion_id.clear(); + }); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.lineage_events.push(event.clone()); + value.lineage_events[0].event_time = "2026-08-01T00:00:01Z".into(); + }); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.lineage_events.push(event.clone()); + value.lineage_events.push(event.clone()); + }); + + let mut overlap = artifact(); + let mut non_overlapping = artifact(); + let topic_id = non_overlapping.activity_intervals[0].topic_id.clone(); + non_overlapping.activity_intervals.extend([ + TopicActivityInterval { + topic_id: topic_id.clone(), + state_code: "dormant".into(), + valid_from: "2026-07-15T00:00:01Z".into(), + valid_to: "2026-07-20T00:00:00Z".into(), + }, + TopicActivityInterval { + topic_id, + state_code: "reactivated".into(), + valid_from: "2026-07-20T00:00:01Z".into(), + valid_to: "2026-08-01T00:00:00Z".into(), + }, + ]); + assert!(non_overlapping.to_json().is_ok()); + let canonical_json = non_overlapping.to_json(); + non_overlapping.activity_intervals.swap(0, 1); + assert_eq!(non_overlapping.to_json(), canonical_json); + overlap.activity_intervals.push(TopicActivityInterval { + topic_id: overlap.activity_intervals[0].topic_id.clone(), + state_code: "dormant".into(), + valid_from: "2026-07-15T00:00:00Z".into(), + valid_to: "2026-07-15T00:00:00Z".into(), + }); + assert!(overlap.to_json().is_err()); + } + + #[test] + fn rejects_incomplete_topic_state_and_lineage_shapes() { + invalid!(|value: &mut TopicContextPosteriorArtifact| value + .activity_intervals + .push(value.activity_intervals[0].clone())); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.activity_intervals[0].state_code = + "reactivated".into() + ); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.activity_intervals.clear()); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.activity_intervals.push(TopicActivityInterval { + topic_id: value.activity_intervals[0].topic_id.clone(), + state_code: "active".into(), + valid_from: "2026-07-15T00:00:01Z".into(), + valid_to: "2026-07-16T00:00:00Z".into(), + }); + }); + let source_topic_id = artifact().topic_ids[0].clone(); + for (event_code, target_topic_id) in + [("split", None), ("merge", Some(source_topic_id.clone()))] + { + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.lineage_events.push(TopicLineageEvent { + event_code: event_code.into(), + source_topic_id: source_topic_id.clone(), + target_topic_id: target_topic_id.clone(), + event_time: "2026-07-15T00:00:00Z".into(), + evidence_sha256: "c".repeat(64), + evidence_resource_id: "evidence-lineage-shape".into(), + provenance_assertion_id: "provenance-lineage-shape".into(), + }); + }); + } + } + + #[test] + fn rejects_invalid_posterior_records() { + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0] + .document_id + .clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0] + .event_time + .clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0].draw_index = 2 + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0] + .logistic_normal_coordinates + .clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0] + .logistic_normal_coordinates[0] = + f64::NAN + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.plausible_values[1].event_time = + "2026-07-16T00:00:00Z".into() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.plausible_values[0].event_time = + "2026-08-01T00:00:01Z".into() + ); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.plausible_values.truncate(2)); + } + + #[test] + fn rejects_invalid_membership_and_relation_records() { + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0].document_id.clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0].dimension_code.clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0].context_id.clear() + ); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.memberships[0].weight = 0.0); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0].weight = f64::NAN + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0].valid_from.clear() + ); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.memberships[0].valid_to.clear()); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0].valid_from = + "2026-07-16T00:00:00Z".into() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0].valid_from = + "2026-08-02T00:00:00Z".into() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0] + .evidence_sha256 + .clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0] + .evidence_resource_id + .clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0] + .provenance_assertion_id + .clear() + ); + invalid!(|value: &mut TopicContextPosteriorArtifact| value + .memberships + .push(value.memberships[0].clone())); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.memberships.remove(0)); + let mut reordered = artifact(); + reordered.memberships.swap(0, 1); + assert_eq!(reordered.to_json(), artifact().to_json()); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0].document_id = + "018f3f7a-7b7c-7d00-8000-000000000003".into() + ); + + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] + .target_document_id = + value.document_relations[0].source_document_id.clone() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] + .source_document_id = + "018f3f7a-7b7c-7d00-8000-000000000003".into() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] + .target_document_id = + "018f3f7a-7b7c-7d00-8000-000000000003".into() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] + .relation_kind_code + .clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.document_relations[0].event_time = + "2026-08-01T00:00:01Z".into() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] + .evidence_sha256 + .clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] + .evidence_resource_id + .clear() + ); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] + .provenance_assertion_id + .clear() + ); + invalid!(|value: &mut TopicContextPosteriorArtifact| value + .document_relations + .push(value.document_relations[0].clone())); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.memberships.truncate(4)); + } + + #[test] + fn rejects_ambiguous_relation_semantics_and_provenance() { + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.document_relations[0] + .relation_kind_code = + "associated_with".into() + ); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + for plausible_value in value + .plausible_values + .iter_mut() + .filter(|item| item.document_id.ends_with("0001")) + { + plausible_value.event_time = "2026-07-16T00:00:00Z".into(); + } + }); + invalid!( + |value: &mut TopicContextPosteriorArtifact| value.memberships[0] + .provenance_assertion_id = + value.document_relations[0].provenance_assertion_id.clone() + ); + + let mut relation_time_reuse = artifact(); + let mut relation = relation_time_reuse.document_relations[0].clone(); + relation.event_time = "2026-07-14T00:00:00Z".into(); + relation_time_reuse.document_relations.push(relation); + assert!(relation_time_reuse.to_json().is_err()); + + let mut lineage_time_reuse = artifact(); + let lineage = TopicLineageEvent { + event_code: "birth".into(), + source_topic_id: lineage_time_reuse.topic_ids[0].clone(), + target_topic_id: None, + event_time: "2026-07-15T00:00:00Z".into(), + evidence_sha256: "c".repeat(64), + evidence_resource_id: "evidence-lineage-time".into(), + provenance_assertion_id: "provenance-lineage-time".into(), + }; + lineage_time_reuse.lineage_events.push(lineage.clone()); + lineage_time_reuse.lineage_events.push(TopicLineageEvent { + event_time: "2026-07-14T00:00:00Z".into(), + ..lineage + }); + assert!(lineage_time_reuse.to_json().is_err()); + + let mut membership_window_reuse = artifact(); + let mut membership = membership_window_reuse.memberships[0].clone(); + membership.valid_from = "2026-06-30T00:00:00Z".into(); + membership_window_reuse.memberships.push(membership); + assert!(membership_window_reuse.to_json().is_err()); + } + + fn append_synthetic_document( + artifact: &mut TopicContextPosteriorArtifact, + document: &str, + context_id: &str, + ) { + for draw in 0..artifact.posterior_draw_count { + artifact.plausible_values.push(TopicPostPlausibleValue { + document_id: document.to_string(), + draw_index: draw, + event_time: "2026-07-15T00:00:00Z".into(), + logistic_normal_coordinates: vec![0.0], + }); + } + for dimension in ["business_unit", "process_unit", "team", "person"] { + artifact.memberships.push(TopicContextMembership { + document_id: document.to_string(), + dimension_code: dimension.into(), + context_id: context_id.to_string(), + weight: 1.0, + valid_from: "2026-07-01T00:00:00Z".into(), + valid_to: "2026-08-01T00:00:00Z".into(), + evidence_sha256: "b".repeat(64), + evidence_resource_id: format!("evidence-{dimension}-{document}"), + provenance_assertion_id: format!("provenance-{dimension}-{document}"), + }); + } + } + + #[test] + fn to_json_refuses_payloads_over_the_canonical_byte_limit() { + // Grow the artifact with fully valid synthetic documents — each with + // the full draw set and all four membership dimensions — until the + // canonical serialization crosses 16 MiB, so the size branch (and + // only the size branch) refuses with LimitExceeded. The count is + // derived from one measured per-document delta, so the loop runs + // once instead of re-serializing the whole payload per step. + let probe = artifact(); + let per_document_bytes = { + let mut one = probe.clone(); + let document = + ::uuid::Uuid::from_u128(0x8000_0000_0000_0000_0000_0000_0000_0000_u128).to_string(); + append_synthetic_document(&mut one, &document, "context-probe"); + serde_json::to_string(&one).expect("probe").len() + - serde_json::to_string(&probe).expect("base").len() + }; + assert!(per_document_bytes > 0); + let extra_documents = TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT / per_document_bytes + 2; + let entries_per_document = + usize::try_from(probe.posterior_draw_count + 4).expect("bounded draw count fits usize"); + let projected_entries = extra_documents * entries_per_document; + assert!( + projected_entries <= ENTRY_LIMIT, + "derived documents must stay inside the entry cap" + ); + let mut oversized = probe; + // Bulk-fill up to the estimate, then top up one document at a time + // (near the limit, only a handful of iterations remain) so rounding + // differences between the probe and the real entries cannot leave the + // payload under the threshold. + for index in 0..extra_documents { + let document = ::uuid::Uuid::from_u128( + 0x8000_0000_0000_0000_0000_0000_0000_0000_u128 + + u128::try_from(index).expect("index fits u128"), + ) + .to_string(); + append_synthetic_document(&mut oversized, &document, &format!("context-{index}")); + } + let mut top_up = extra_documents; + while serde_json::to_string(&oversized) + .expect("canonical serialization") + .len() + <= TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT + { + assert!( + top_up < extra_documents + 1_000, + "byte limit not crossed within the top-up budget" + ); + let document = ::uuid::Uuid::from_u128( + 0x8000_0000_0000_0000_0000_0000_0000_0000_u128 + + u128::try_from(top_up).expect("fits"), + ) + .to_string(); + append_synthetic_document( + &mut oversized, + &document, + &format!("context-topup-{top_up}"), + ); + top_up += 1; + } + assert_eq!(oversized.to_json(), Err(AnalysisEngineError::LimitExceeded)); + } + + #[test] + fn canonicalises_out_of_order_lineage_and_relation_sorts() { + let mut value = artifact(); + let topic_a = value.topic_ids[0].clone(); + let topic_b = value.topic_ids[1].clone(); + + // birth/retirement require no target; split/merge may self-reference. + let event_later = TopicLineageEvent { + event_code: "retirement".into(), + source_topic_id: topic_b.clone(), + target_topic_id: None, + event_time: "2026-07-20T00:00:00Z".into(), + evidence_sha256: "b".repeat(64), + evidence_resource_id: "evidence-lineage-later".into(), + provenance_assertion_id: "provenance-lineage-later".into(), + }; + let event_earlier = TopicLineageEvent { + event_code: "birth".into(), + source_topic_id: topic_a.clone(), + target_topic_id: None, + event_time: "2026-07-10T00:00:00Z".into(), + evidence_sha256: "a".repeat(64), + evidence_resource_id: "evidence-lineage-earlier".into(), + provenance_assertion_id: "provenance-lineage-earlier".into(), + }; + + let relation_later = TopicDocumentRelation { + source_document_id: "018f3f7a-7b7c-7d00-8000-000000000002".into(), + target_document_id: "018f3f7a-7b7c-7d00-8000-000000000001".into(), + relation_kind_code: "event_lineage_precedes".into(), + event_time: "2026-07-20T00:00:00Z".into(), + evidence_sha256: "d".repeat(64), + evidence_resource_id: "evidence-relation-later".into(), + provenance_assertion_id: "provenance-relation-later".into(), + }; + let relation_earlier = TopicDocumentRelation { + source_document_id: "018f3f7a-7b7c-7d00-8000-000000000001".into(), + target_document_id: "018f3f7a-7b7c-7d00-8000-000000000002".into(), + relation_kind_code: "event_lineage_precedes".into(), + event_time: "2026-07-10T00:00:00Z".into(), + evidence_sha256: "c".repeat(64), + evidence_resource_id: "evidence-relation-earlier".into(), + provenance_assertion_id: "provenance-relation-earlier".into(), + }; + + value.lineage_events = vec![event_later, event_earlier]; + value.document_relations = vec![relation_later, relation_earlier]; + let json = value.to_json().expect("canonical serialisation"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json"); + + let events = parsed["lineage_events"].as_array().expect("events array"); + assert_eq!(events.len(), 2); + assert_eq!(events[0]["event_code"], "birth"); + assert_eq!(events[1]["event_code"], "retirement"); + + let relations = parsed["document_relations"].as_array().expect("relations"); + assert_eq!(relations.len(), 2); + assert_eq!( + relations[0]["source_document_id"], + "018f3f7a-7b7c-7d00-8000-000000000001" + ); + assert_eq!( + relations[1]["source_document_id"], + "018f3f7a-7b7c-7d00-8000-000000000002" + ); + + let round_tripped = TopicContextPosteriorArtifact::from_json(&json).expect("round-trip"); + assert_eq!( + round_tripped.lineage_events[0].event_code, "birth", + "canonical lineage order survives round-trip" + ); + assert_eq!( + round_tripped.document_relations[0].source_document_id, + "018f3f7a-7b7c-7d00-8000-000000000001", + "canonical relation order survives round-trip" + ); + } +} From 1bf55d91c5274954d5062fff64b13aa1f0be769f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 14:09:20 +0900 Subject: [PATCH 05/14] fix(analysis): keep posterior boundary warning-clean --- crates/analysis_engine/src/topic_context_posterior.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/analysis_engine/src/topic_context_posterior.rs b/crates/analysis_engine/src/topic_context_posterior.rs index 3dc284d04..5f1d05609 100644 --- a/crates/analysis_engine/src/topic_context_posterior.rs +++ b/crates/analysis_engine/src/topic_context_posterior.rs @@ -11,7 +11,7 @@ use tepp_api::{ AnalysisResultSummary, AnalysisRunAccepted, AnalysisRunRequest, AnalysisRunTerminalResult, }; -use crate::{AnalysisEngineError, format_digest, require_receipt_identity, valid_identifier}; +use crate::{AnalysisEngineError, require_receipt_identity, valid_identifier}; mod base { include!("topic_context_posterior_base.rs"); From 91407b566764519ce83ca3609c1b98d30f6fdc42 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 14:09:37 +0900 Subject: [PATCH 06/14] test(analysis): bound topic posterior snapshot manifest wire --- ...pic_context_posterior_manifest_contract.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs diff --git a/crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs b/crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs new file mode 100644 index 000000000..88ccc2c59 --- /dev/null +++ b/crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs @@ -0,0 +1,47 @@ +//! Wire contract for the topic-context posterior snapshot manifest. + +use std::collections::BTreeMap; + +use analysis_engine::{ + AnalysisEngineError, TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TopicContextPosteriorSnapshotManifest, +}; + +fn manifest() -> TopicContextPosteriorSnapshotManifest { + TopicContextPosteriorSnapshotManifest { + snapshot_id: "snapshot-topic-context-posterior".into(), + source_snapshot_sha256: "0".repeat(64), + knowledge_cutoff: "2026-08-01T00:00:00Z".into(), + artifact_sha256: "1".repeat(64), + document_available_at: BTreeMap::from([( + "018f3f7a-7b7c-7d00-8000-000000000001".into(), + "2026-07-20T00:00:00Z".into(), + )]), + } +} + +#[test] +fn manifest_round_trips_through_the_bounded_validated_wire_contract() { + let expected = manifest(); + let json = expected.to_json().expect("manifest json"); + assert!(json.len() < TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT); + assert_eq!( + TopicContextPosteriorSnapshotManifest::from_json(&json).expect("manifest parse"), + expected + ); +} + +#[test] +fn manifest_parser_rejects_unknown_fields_and_oversized_payloads() { + let json = manifest().to_json().expect("manifest json"); + let unknown = json.replacen('{', "{\"unexpected\":true,", 1); + assert_eq!( + TopicContextPosteriorSnapshotManifest::from_json(&unknown), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let oversized = "x".repeat(TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT + 1); + assert_eq!( + TopicContextPosteriorSnapshotManifest::from_json(&oversized), + Err(AnalysisEngineError::LimitExceeded) + ); +} From 2250b697e15d1b971b124b3e7b5622063622307b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 14:10:00 +0900 Subject: [PATCH 07/14] docs(adr): keep posterior analysis decision proposed --- ...68-topic-context-posterior-analysis-run.md | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/docs/adr/0068-topic-context-posterior-analysis-run.md b/docs/adr/0068-topic-context-posterior-analysis-run.md index bce2dbf69..fa8720cbf 100644 --- a/docs/adr/0068-topic-context-posterior-analysis-run.md +++ b/docs/adr/0068-topic-context-posterior-analysis-run.md @@ -1,6 +1,6 @@ # ADR 0068 — Posterior topic-context producer as an analysis-run output profile -**Decision status:** Accepted +**Decision status:** Proposed **Implementation maturity:** active-PR — composed on this branch; not implemented-main **Date:** 2026-09-01 **Supersedes:** None; complements ADR 0022 (cutoff-safe analysis-run execution) and ADR 0024 (posterior topic-context producer contract). @@ -32,14 +32,19 @@ Add the `topic_context_posterior_v1` analysis-run output profile to - requires an authoritative snapshot manifest to bind the request and artifact snapshot identity, source digest, cutoff, exact artifact digest, and every represented document's availability time; +- compares request, artifact, and manifest knowledge cutoffs by temporal instant + while requiring canonical RFC 3339 for persisted artifact/manifest evidence; +- validates and serializes the snapshot manifest through the same bounded 16 MiB + wire envelope used by the posterior artifact rather than accepting an + unbounded in-memory side contract; - rejects missing, extra, malformed, or post-cutoff document availability and artifacts not emitted under the approved `trsl-tm-v1` producer contract; - invokes the existing producer `sha256`/validate path without reimplementing TRSL-TM fitting; - emits a digest-bound terminal result under - `tepp.topic_context_posterior.v1` with inference status - `posterior_topic_coordinates_not_importance` and counts the coordinates - actually present rather than a fixed statistic count; + `tepp.topic_context_posterior.v1`; provider validation remains `validated`, + while the artifact retains the scientific inference boundary + `posterior_topic_coordinates_not_importance`; - refuses reuse of `lineage_criterion_v1`, `case_deletion_refit_v1`, `composed_fitted_lineage_v1`, `fitted_candidate_k_v1`, `trsl_topic_lineage_v1`, and `method_effects_v1` as this profile; @@ -47,6 +52,12 @@ Add the `topic_context_posterior_v1` analysis-run output profile to infer topic importance, or emit invented birth/split/merge events. Lineage events remain producer-supplied. +Supporting lineage/relation/membership evidence-resource availability is not yet +represented by the current manifest shape. The profile therefore remains +Proposed: it must not be promoted to implemented-main or scientific acceptance +until those support resources are independently cutoff-bound instead of relying +only on represented-document availability. + This is posterior topic coordinates, not importance and not a sampler. ## Alternatives considered @@ -57,23 +68,30 @@ This is posterior topic coordinates, not importance and not a sampler. because those functions do not exist on protected main as executors. 3. Collapse missing draws into a point estimate — rejected because the producer contract already fails closed on incomplete draw sets. -4. Bind the existing producer validator to ADR 0022's analysis-run - profile — accepted. +4. Compare RFC 3339 cutoff strings byte-for-byte — rejected because equivalent + offset representations can denote the same instant. +5. Treat artifact inference language as terminal provider validation — rejected + because scientific interpretation and execution validation are distinct claims. +6. Bind the existing producer validator to ADR 0022's analysis-run + profile — selected, subject to the remaining support-evidence availability gate. ## Consequences -Operators can request cutoff-safe posterior topic-context validation as a -digest-bound terminal result. The artifact does not claim topic +Operators can exercise the draft profile with instant-safe cutoff comparison and +a bounded snapshot-manifest wire contract. The artifact does not claim topic importance, Bayesian sampling, GPU parity, or invented birth/split/merge. -Snapshot/profile/cutoff/digest mismatch, incomplete cutoff eligibility, and -producer-contract refusal fail closed. +Snapshot/profile/cutoff/digest mismatch, incomplete document cutoff eligibility, +and producer-contract refusal fail closed. Support evidence-resource availability +remains an explicit merge blocker rather than being silently treated as document +availability. ## Verification -The PR includes Rust integration tests for successful digest-bound -coordinates, incomplete draw refusal, run-identity mismatch, -snapshot/profile/cutoff/source/artifact-digest mismatch, future evidence, -producer-contract mismatch, and reuse of live sibling profiles. Run: +The PR includes Rust integration tests for equivalent cutoff instants, terminal +validation/inference separation, bounded manifest round-trip and rejection, +successful digest-bound coordinates, incomplete draw refusal, run-identity +mismatch, snapshot/profile/source/artifact-digest mismatch, future document +evidence, producer-contract mismatch, and reuse of live sibling profiles. Run: ```text cargo fmt --all -- --check @@ -87,4 +105,4 @@ python3 scripts/validate_documentation.py Rollback removes the `topic_context_posterior_v1` profile. No persisted schema migration is introduced. Supersede only with an ADR that keeps posterior coordinates distinct from importance, sampling, and invented -lineage events. +lineage events and preserves leakage-safe support-evidence admission. From 0042422275bd3725e7e1ca9cb92e885a0ff0e8ce Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 14:57:23 +0900 Subject: [PATCH 08/14] test(analysis): require support evidence availability ledger --- .../topic_context_posterior_manifest_contract.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs b/crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs index 88ccc2c59..b6b14238d 100644 --- a/crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs +++ b/crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs @@ -30,6 +30,21 @@ fn manifest_round_trips_through_the_bounded_validated_wire_contract() { ); } +#[test] +fn manifest_requires_an_explicit_support_evidence_availability_ledger() { + let json = manifest().to_json().expect("manifest json"); + let mut value: serde_json::Value = serde_json::from_str(&json).expect("manifest value"); + value["support_evidence_available_at"] = serde_json::json!({ + "evidence-relation-1": "2026-07-20T00:00:00Z" + }); + let payload = serde_json::to_string(&value).expect("manifest payload"); + + assert!( + TopicContextPosteriorSnapshotManifest::from_json(&payload).is_ok(), + "the authoritative manifest must carry independent availability for supporting evidence resources" + ); +} + #[test] fn manifest_parser_rejects_unknown_fields_and_oversized_payloads() { let json = manifest().to_json().expect("manifest json"); From ae867a95bb1ac525b014cf78224fcde0fa862438 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 14:58:00 +0900 Subject: [PATCH 09/14] fix(analysis): bind posterior support evidence availability --- .../src/topic_context_posterior.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/analysis_engine/src/topic_context_posterior.rs b/crates/analysis_engine/src/topic_context_posterior.rs index 5f1d05609..0d161ee00 100644 --- a/crates/analysis_engine/src/topic_context_posterior.rs +++ b/crates/analysis_engine/src/topic_context_posterior.rs @@ -62,6 +62,8 @@ pub struct TopicContextPosteriorSnapshotManifest { pub artifact_sha256: String, /// Availability instant for every document represented by the artifact. pub document_available_at: BTreeMap, + /// Availability instant for every lineage, relation, or membership evidence resource used. + pub support_evidence_available_at: BTreeMap, } impl TopicContextPosteriorSnapshotManifest { @@ -71,10 +73,18 @@ impl TopicContextPosteriorSnapshotManifest { || !digest(&self.artifact_sha256) || canonical_cutoff(&self.knowledge_cutoff).is_none() || self.document_available_at.len() > MANIFEST_ENTRY_LIMIT + || self.support_evidence_available_at.len() > MANIFEST_ENTRY_LIMIT || self.document_available_at.iter().any(|(document_id, available_at)| { !valid_identifier(document_id) || canonical_available_time(available_at).is_none() }) + || self + .support_evidence_available_at + .iter() + .any(|(evidence_resource_id, available_at)| { + !valid_identifier(evidence_resource_id) + || canonical_available_time(available_at).is_none() + }) { return Err(AnalysisEngineError::InvalidEvidence); } @@ -180,6 +190,35 @@ pub fn execute_topic_context_posterior_run( return Err(AnalysisEngineError::InvalidEvidence); } + let support_evidence_ids: BTreeSet<&str> = artifact + .lineage_events + .iter() + .map(|event| event.evidence_resource_id.as_str()) + .chain( + artifact + .document_relations + .iter() + .map(|relation| relation.evidence_resource_id.as_str()), + ) + .chain( + artifact + .memberships + .iter() + .map(|membership| membership.evidence_resource_id.as_str()), + ) + .collect(); + if support_evidence_ids.len() != manifest.support_evidence_available_at.len() + || support_evidence_ids.iter().any(|evidence_resource_id| { + manifest + .support_evidence_available_at + .get(*evidence_resource_id) + .and_then(|available_at| canonical_available_time(available_at)) + .is_none_or(|available_at| available_at.instant() > manifest_cutoff.instant()) + }) + { + return Err(AnalysisEngineError::InvalidEvidence); + } + let document_count = u64::try_from(document_ids.len()) .map_err(|_| AnalysisEngineError::LimitExceeded)?; let statistic_count = artifact.plausible_values.iter().try_fold(0_u64, |total, value| { From 875a8224c9c2c2dae1a287d405c0d831c133032d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 14:58:27 +0900 Subject: [PATCH 10/14] test(analysis): validate support evidence availability ledger --- ...pic_context_posterior_manifest_contract.rs | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs b/crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs index b6b14238d..eea68951a 100644 --- a/crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs +++ b/crates/analysis_engine/tests/topic_context_posterior_manifest_contract.rs @@ -16,6 +16,10 @@ fn manifest() -> TopicContextPosteriorSnapshotManifest { "018f3f7a-7b7c-7d00-8000-000000000001".into(), "2026-07-20T00:00:00Z".into(), )]), + support_evidence_available_at: BTreeMap::from([( + "evidence-relation-1".into(), + "2026-07-20T00:00:00Z".into(), + )]), } } @@ -34,14 +38,15 @@ fn manifest_round_trips_through_the_bounded_validated_wire_contract() { fn manifest_requires_an_explicit_support_evidence_availability_ledger() { let json = manifest().to_json().expect("manifest json"); let mut value: serde_json::Value = serde_json::from_str(&json).expect("manifest value"); - value["support_evidence_available_at"] = serde_json::json!({ - "evidence-relation-1": "2026-07-20T00:00:00Z" - }); + value + .as_object_mut() + .expect("manifest object") + .remove("support_evidence_available_at"); let payload = serde_json::to_string(&value).expect("manifest payload"); - assert!( - TopicContextPosteriorSnapshotManifest::from_json(&payload).is_ok(), - "the authoritative manifest must carry independent availability for supporting evidence resources" + assert_eq!( + TopicContextPosteriorSnapshotManifest::from_json(&payload), + Err(AnalysisEngineError::InvalidEvidence) ); } @@ -60,3 +65,17 @@ fn manifest_parser_rejects_unknown_fields_and_oversized_payloads() { Err(AnalysisEngineError::LimitExceeded) ); } + +#[test] +fn manifest_rejects_malformed_support_availability() { + let mut malformed = manifest(); + *malformed + .support_evidence_available_at + .first_entry() + .expect("support evidence") + .get_mut() = "not-a-time".into(); + assert_eq!( + malformed.to_json(), + Err(AnalysisEngineError::InvalidEvidence) + ); +} From 8dcd00bfdcee035423fae38bb596ec07ce604645 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 14:58:46 +0900 Subject: [PATCH 11/14] test(analysis): bind temporal fixture support provenance --- ...pic_context_posterior_temporal_contract.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/analysis_engine/tests/topic_context_posterior_temporal_contract.rs b/crates/analysis_engine/tests/topic_context_posterior_temporal_contract.rs index a7cfa6de3..68725eb80 100644 --- a/crates/analysis_engine/tests/topic_context_posterior_temporal_contract.rs +++ b/crates/analysis_engine/tests/topic_context_posterior_temporal_contract.rs @@ -96,6 +96,29 @@ fn request(cutoff: &str) -> AnalysisRunRequest { } } +fn support_evidence_available_at( + artifact: &TopicContextPosteriorArtifact, +) -> BTreeMap { + artifact + .lineage_events + .iter() + .map(|event| event.evidence_resource_id.clone()) + .chain( + artifact + .document_relations + .iter() + .map(|relation| relation.evidence_resource_id.clone()), + ) + .chain( + artifact + .memberships + .iter() + .map(|membership| membership.evidence_resource_id.clone()), + ) + .map(|evidence_resource_id| (evidence_resource_id, "2026-08-01T00:00:00Z".into())) + .collect() +} + fn manifest(artifact: &TopicContextPosteriorArtifact) -> TopicContextPosteriorSnapshotManifest { TopicContextPosteriorSnapshotManifest { snapshot_id: artifact.snapshot_id.clone(), @@ -112,6 +135,7 @@ fn manifest(artifact: &TopicContextPosteriorArtifact) -> TopicContextPosteriorSn "2026-08-01T00:00:00Z".into(), ), ]), + support_evidence_available_at: support_evidence_available_at(artifact), } } From c9c3e36740a44fbb1c71df6ee68a31313d103625 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 14:59:35 +0900 Subject: [PATCH 12/14] test(analysis): fail closed on late posterior support evidence --- ...ic_context_posterior_execution_contract.rs | 93 ++++++++++++++++++- 1 file changed, 90 insertions(+), 3 deletions(-) diff --git a/crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs b/crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs index e2c3b8d31..14195a079 100644 --- a/crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs +++ b/crates/analysis_engine/tests/topic_context_posterior_execution_contract.rs @@ -97,6 +97,29 @@ fn request() -> AnalysisRunRequest { } } +fn support_evidence_available_at( + artifact: &TopicContextPosteriorArtifact, +) -> BTreeMap { + artifact + .lineage_events + .iter() + .map(|event| event.evidence_resource_id.clone()) + .chain( + artifact + .document_relations + .iter() + .map(|relation| relation.evidence_resource_id.clone()), + ) + .chain( + artifact + .memberships + .iter() + .map(|membership| membership.evidence_resource_id.clone()), + ) + .map(|evidence_resource_id| (evidence_resource_id, "2026-08-01T00:00:00Z".into())) + .collect() +} + fn manifest(artifact: &TopicContextPosteriorArtifact) -> TopicContextPosteriorSnapshotManifest { TopicContextPosteriorSnapshotManifest { snapshot_id: artifact.snapshot_id.clone(), @@ -113,6 +136,7 @@ fn manifest(artifact: &TopicContextPosteriorArtifact) -> TopicContextPosteriorSn "2026-08-01T00:00:00Z".into(), ), ]), + support_evidence_available_at: support_evidence_available_at(artifact), } } @@ -303,8 +327,8 @@ fn execution_binds_snapshot_digest_availability_and_producer_contract() { Err(AnalysisEngineError::InvalidEvidence) ); - let mut future_evidence = manifest(&artifact); - future_evidence.document_available_at.insert( + let mut future_document = manifest(&artifact); + future_document.document_available_at.insert( "018f3f7a-7b7c-7d00-8000-000000000001".into(), "2026-08-01T00:00:01Z".into(), ); @@ -312,7 +336,23 @@ fn execution_binds_snapshot_digest_availability_and_producer_contract() { execute_topic_context_posterior_run( &request, &accepted(&request), - &future_evidence, + &future_document, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + + let mut future_support = manifest(&artifact); + future_support.support_evidence_available_at.insert( + "evidence-relation-1".into(), + "2026-08-01T00:00:01Z".into(), + ); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &future_support, &artifact, "2026-08-02T00:00:00Z", ), @@ -388,6 +428,19 @@ fn execution_rejects_unbound_artifact_and_availability_manifests() { Err(AnalysisEngineError::InvalidEvidence) ); + let mut incomplete_support = manifest(&artifact); + incomplete_support.support_evidence_available_at.pop_first(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &incomplete_support, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + let mut malformed_availability = manifest(&artifact); *malformed_availability .document_available_at @@ -405,6 +458,23 @@ fn execution_rejects_unbound_artifact_and_availability_manifests() { Err(AnalysisEngineError::InvalidEvidence) ); + let mut malformed_support = manifest(&artifact); + *malformed_support + .support_evidence_available_at + .first_entry() + .expect("support evidence") + .get_mut() = "not-a-time".into(); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &malformed_support, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); + let mut malformed_manifest_digest = manifest(&artifact); malformed_manifest_digest.artifact_sha256 = "not-a-digest".into(); assert_eq!( @@ -449,6 +519,23 @@ fn execution_rejects_unbound_artifact_and_availability_manifests() { ), Err(AnalysisEngineError::InvalidEvidence) ); + + let mut substituted_support = manifest(&artifact); + substituted_support.support_evidence_available_at.pop_first(); + substituted_support.support_evidence_available_at.insert( + "evidence-foreign-resource".into(), + "2026-07-20T00:00:00Z".into(), + ); + assert_eq!( + execute_topic_context_posterior_run( + &request, + &accepted(&request), + &substituted_support, + &artifact, + "2026-08-02T00:00:00Z", + ), + Err(AnalysisEngineError::InvalidEvidence) + ); } #[test] From 3b6de91d779b97853d1369633b94e87007e0cc0a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 15:00:00 +0900 Subject: [PATCH 13/14] docs(adr): record exact-set posterior support availability --- ...68-topic-context-posterior-analysis-run.md | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/docs/adr/0068-topic-context-posterior-analysis-run.md b/docs/adr/0068-topic-context-posterior-analysis-run.md index fa8720cbf..1e62f781f 100644 --- a/docs/adr/0068-topic-context-posterior-analysis-run.md +++ b/docs/adr/0068-topic-context-posterior-analysis-run.md @@ -30,17 +30,23 @@ Add the `topic_context_posterior_v1` analysis-run output profile to - consumes an already-constructed `TopicContextPosteriorArtifact`; - requires an authoritative snapshot manifest to bind the request and artifact - snapshot identity, source digest, cutoff, exact artifact digest, and every - represented document's availability time; + snapshot identity, source digest, cutoff, exact artifact digest, every + represented document's availability time, and every lineage/relation/membership + `evidence_resource_id` actually used by the artifact; +- requires the support-evidence availability ledger to be an exact set match to + the artifact's used evidence-resource identities: missing and substituted + resources fail closed, and no used support resource may have + `AvailableTime > KnowledgeCutoff`; - compares request, artifact, and manifest knowledge cutoffs by temporal instant while requiring canonical RFC 3339 for persisted artifact/manifest evidence; - validates and serializes the snapshot manifest through the same bounded 16 MiB wire envelope used by the posterior artifact rather than accepting an unbounded in-memory side contract; -- rejects missing, extra, malformed, or post-cutoff document availability and - artifacts not emitted under the approved `trsl-tm-v1` producer contract; +- rejects missing, extra, malformed, or post-cutoff document/support availability + and artifacts not emitted under the approved `trsl-tm-v1` producer contract; - invokes the existing producer `sha256`/validate path without - reimplementing TRSL-TM fitting; + reimplementing TRSL-TM fitting or subtracting post-cutoff support from an + already-fitted posterior; - emits a digest-bound terminal result under `tepp.topic_context_posterior.v1`; provider validation remains `validated`, while the artifact retains the scientific inference boundary @@ -52,11 +58,10 @@ Add the `topic_context_posterior_v1` analysis-run output profile to infer topic importance, or emit invented birth/split/merge events. Lineage events remain producer-supplied. -Supporting lineage/relation/membership evidence-resource availability is not yet -represented by the current manifest shape. The profile therefore remains -Proposed: it must not be promoted to implemented-main or scientific acceptance -until those support resources are independently cutoff-bound instead of relying -only on represented-document availability. +The manifest-level support-evidence contract is intentionally admission-only. +It does not reinterpret producer scientific evidence, alter posterior weights, +or make a late support row historical. A posterior whose actually used support +includes any resource unavailable at the requested cutoff is refused as a whole. This is posterior topic coordinates, not importance and not a sampler. @@ -72,26 +77,35 @@ This is posterior topic coordinates, not importance and not a sampler. offset representations can denote the same instant. 5. Treat artifact inference language as terminal provider validation — rejected because scientific interpretation and execution validation are distinct claims. -6. Bind the existing producer validator to ADR 0022's analysis-run - profile — selected, subject to the remaining support-evidence availability gate. +6. Infer support availability from represented-document availability — rejected + because relation, lineage, and membership evidence can become available later + than the documents they support. +7. Drop post-cutoff support rows from an already-fitted posterior — rejected + because that would misrepresent the fitted scientific artifact rather than + reconstructing it from a historically eligible evidence set. +8. Bind the existing producer validator to ADR 0022's analysis-run profile with + an exact-set document/support availability manifest — selected. ## Consequences Operators can exercise the draft profile with instant-safe cutoff comparison and a bounded snapshot-manifest wire contract. The artifact does not claim topic importance, Bayesian sampling, GPU parity, or invented birth/split/merge. -Snapshot/profile/cutoff/digest mismatch, incomplete document cutoff eligibility, -and producer-contract refusal fail closed. Support evidence-resource availability -remains an explicit merge blocker rather than being silently treated as document -availability. +Snapshot/profile/cutoff/digest mismatch, incomplete document or support cutoff +eligibility, substituted support identities, future support evidence, and +producer-contract refusal fail closed. The profile remains Proposed until this +branch is validated and integrated into protected main; branch-local completion +is not implementation-main authority. ## Verification The PR includes Rust integration tests for equivalent cutoff instants, terminal validation/inference separation, bounded manifest round-trip and rejection, -successful digest-bound coordinates, incomplete draw refusal, run-identity -mismatch, snapshot/profile/source/artifact-digest mismatch, future document -evidence, producer-contract mismatch, and reuse of live sibling profiles. Run: +mandatory support-evidence availability, successful digest-bound coordinates, +incomplete draw refusal, run-identity mismatch, snapshot/profile/source/artifact +digest mismatch, future document evidence, future support evidence, missing or +substituted support resources, producer-contract mismatch, and reuse of live +sibling profiles. Run: ```text cargo fmt --all -- --check @@ -105,4 +119,5 @@ python3 scripts/validate_documentation.py Rollback removes the `topic_context_posterior_v1` profile. No persisted schema migration is introduced. Supersede only with an ADR that keeps posterior coordinates distinct from importance, sampling, and invented -lineage events and preserves leakage-safe support-evidence admission. +lineage events and preserves leakage-safe document and support-evidence +admission. From 021843391c8b450e728703e4ec721b2f4a5b3927 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 14 Sep 2026 15:00:12 +0900 Subject: [PATCH 14/14] docs(doctoring): bind posterior support availability contract --- .../topic-context-posterior-analysis-run.md | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/doctoring/topic-context-posterior-analysis-run.md b/docs/doctoring/topic-context-posterior-analysis-run.md index c4a365bed..d5054762e 100644 --- a/docs/doctoring/topic-context-posterior-analysis-run.md +++ b/docs/doctoring/topic-context-posterior-analysis-run.md @@ -10,13 +10,20 @@ request a digest-bound terminal result. Execution requires one authoritative snapshot manifest. It binds the source snapshot digest and exact artifact digest, provides an availability instant for -every represented document, rejects evidence available after the historical -cutoff, and admits only the `trsl-tm-v1` producer contract. The terminal summary -counts the logistic-normal coordinates actually validated. +every represented document, and independently binds the exact set of lineage, +relation, and membership `evidence_resource_id` values actually used by the +artifact. Missing, substituted, malformed, or post-cutoff document/support +availability fails closed. The manifest admits only the `trsl-tm-v1` producer +contract, and the terminal summary counts the logistic-normal coordinates +actually validated. -The executor does not infer topic importance, does not collapse missing -draws, and does not invent birth/split/merge events. Lineage events remain -producer-supplied. It is not a Bayesian sampler and not GPU execution. +The support ledger is admission evidence, not a posterior rewrite mechanism. +The executor does not subtract late support from an already-fitted artifact, +infer topic importance, collapse missing draws, or invent birth/split/merge +events. Lineage events remain producer-supplied. It is not a Bayesian sampler +and not GPU execution. -Exact-head Checks and two independent approvals are required before any -implemented-main claim. +ADR 0068 remains Proposed until the branch is validated and integrated into +protected main. Exact-head required workflows, resolved review threads, and the +qualifying current-head approval required by the live ruleset must be satisfied +before any implemented-main claim.