diff --git a/crates/analysis_engine/src/topic_context_posterior.rs b/crates/analysis_engine/src/topic_context_posterior.rs index 5edb15a4d..95a9fc1a4 100644 --- a/crates/analysis_engine/src/topic_context_posterior.rs +++ b/crates/analysis_engine/src/topic_context_posterior.rs @@ -158,6 +158,8 @@ pub struct TopicContextPosteriorArtifact { pub topic_count: u64, /// Stable topic identities in logistic-normal coordinate order. pub topic_ids: Vec, + /// Digest binding the draw-set identity to the ordered fitted topic basis. + pub topic_basis_sha256: String, /// Explicit topic-state intervals. pub activity_intervals: Vec, /// Explicit topic lineage events, when present. @@ -256,6 +258,7 @@ pub fn assemble_topic_context_posterior( logistic_normal_coordinates: value.logistic_normal_coordinates, }) .collect(); + let topic_ids: Vec<_> = fitted_topic_ids.iter().map(Uuid::to_string).collect(); let artifact = TopicContextPosteriorArtifact { schema_version: TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION.into(), run_id: accepted.run_id.clone(), @@ -269,7 +272,8 @@ pub fn assemble_topic_context_posterior( .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?, topic_count: u64::try_from(fitted_topic_ids.len()) .map_err(|_| AnalysisEngineError::ArithmeticOverflow)?, - topic_ids: fitted_topic_ids.iter().map(Uuid::to_string).collect(), + topic_basis_sha256: topic_basis_binding(draws.draw_set_id(), &topic_ids), + topic_ids, activity_intervals, lineage_events, document_relations, @@ -323,6 +327,13 @@ fn provenance_binding(fields: &[&[u8]]) -> String { format_digest(digest.finalize()) } +fn topic_basis_binding(draw_set_id: &str, topic_ids: &[String]) -> String { + let mut fields = Vec::with_capacity(topic_ids.len() + 1); + fields.push(draw_set_id.as_bytes()); + fields.extend(topic_ids.iter().map(String::as_bytes)); + provenance_binding(&fields) +} + fn time(value: &str) -> Option { KnowledgeCutoff::parse_rfc3339(value).ok() } @@ -486,6 +497,9 @@ impl TopicContextPosteriorArtifact { 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() + || !digest(&self.topic_basis_sha256) + || self.topic_basis_sha256 + != topic_basis_binding(&self.posterior_draw_set_id, &self.topic_ids) || self .topic_ids .iter() @@ -795,7 +809,8 @@ mod tests { use super::{ ENTRY_LIMIT, TOPIC_CONTEXT_POSTERIOR_BYTE_LIMIT, TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION, TopicActivityInterval, TopicContextMembership, TopicContextPosteriorArtifact, - TopicDocumentRelation, TopicLineageEvent, TopicPostPlausibleValue, within_entry_limits, + TopicDocumentRelation, TopicLineageEvent, TopicPostPlausibleValue, topic_basis_binding, + within_entry_limits, }; macro_rules! invalid { @@ -811,6 +826,10 @@ mod tests { "018f3f7a-7b7c-7d00-8000-000000000001", "018f3f7a-7b7c-7d00-8000-000000000002", ]; + let topic_ids = vec![ + "018f3f7a-7b7c-7d00-8000-000000000101".into(), + "018f3f7a-7b7c-7d00-8000-000000000102".into(), + ]; TopicContextPosteriorArtifact { schema_version: TOPIC_CONTEXT_POSTERIOR_SCHEMA_VERSION.into(), run_id: "run-1".into(), @@ -822,10 +841,8 @@ mod tests { 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(), - ], + topic_basis_sha256: topic_basis_binding("draw-set-1", &topic_ids), + topic_ids, activity_intervals: [ "018f3f7a-7b7c-7d00-8000-000000000101", "018f3f7a-7b7c-7d00-8000-000000000102", @@ -961,14 +978,24 @@ mod tests { 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_set_id = + "draw-set-2".into() + ); 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_basis_sha256.clear()); + invalid!(|value: &mut TopicContextPosteriorArtifact| { + value.topic_ids[0].clear(); + value.topic_basis_sha256 = + topic_basis_binding(&value.posterior_draw_set_id, &value.topic_ids); + }); invalid!( |value: &mut TopicContextPosteriorArtifact| value.topic_ids[1] = value.topic_ids[0].clone() ); + invalid!(|value: &mut TopicContextPosteriorArtifact| value.topic_ids.swap(0, 1)); invalid!(|value: &mut TopicContextPosteriorArtifact| value.inference_status.clear()); assert!(within_entry_limits([ENTRY_LIMIT; 5], ENTRY_LIMIT)); assert!(!within_entry_limits( diff --git a/docs/API_CONTRACT.md b/docs/API_CONTRACT.md index b76b688e1..f059b1276 100644 --- a/docs/API_CONTRACT.md +++ b/docs/API_CONTRACT.md @@ -114,7 +114,8 @@ posterior logistic-normal plausible values, a declared event clock, opaque stable topic identities, artifact-local coordinate order, topic activity, explicit topic-lineage events, admitted Event Lineage document relations, and provenance-bound time-valid business-unit, PU, team, and person memberships. -The ordered `topic_ids` array defines coordinate order. Each lineage, +The ordered `topic_ids` array defines coordinate order; `topic_basis_sha256` +binds that order to `posterior_draw_set_id` and fails closed on relabeling. Each lineage, document-relation, or membership assertion identifies its immutable evidence resource, provenance assertion, and digest so consumers can materialize normalized qualified provenance. It is the only admitted handoff to the diff --git a/docs/adr/0024-independent-topic-importance-anchor.md b/docs/adr/0024-independent-topic-importance-anchor.md index 8eb7de294..18b0c8c98 100644 --- a/docs/adr/0024-independent-topic-importance-anchor.md +++ b/docs/adr/0024-independent-topic-importance-anchor.md @@ -103,7 +103,8 @@ The analysis layer now assembles a complete accepted run, bound source snapshot and cutoff, declared event clock, stable topic activity, qualified document-lineage evidence, and time-valid membership provenance. It derives plausible values from the fit-bound joint precision and -validates the completed artifact before returning it. Missing or mismatched +binds the ordered topic identities to the posterior draw-set identity before +validating the completed artifact. Missing or mismatched records remain unavailable; the assembler does not infer a predecessor, fill a membership, or manufacture a topic-lineage event. Multiple outgoing admitted predecessor relations remain separate records, so a Project Journey consumer diff --git a/schemas/topic_context_posterior_v1.json b/schemas/topic_context_posterior_v1.json index 58dafed35..d7cf0c886 100644 --- a/schemas/topic_context_posterior_v1.json +++ b/schemas/topic_context_posterior_v1.json @@ -5,7 +5,7 @@ "$comment": "This schema validates record shapes. Cross-record completeness (distinct documents, complete document-by-draw grids, and four time-covering membership dimensions per document) is normative Rust domain validation because JSON Schema cannot express those joins without duplicating document identities.", "type": "object", "additionalProperties": false, - "required": ["schema_version", "run_id", "snapshot_id", "source_snapshot_sha256", "knowledge_cutoff", "event_clock_code", "model_contract_version", "posterior_draw_set_id", "posterior_draw_count", "topic_count", "topic_ids", "activity_intervals", "lineage_events", "document_relations", "plausible_values", "memberships", "inference_status"], + "required": ["schema_version", "run_id", "snapshot_id", "source_snapshot_sha256", "knowledge_cutoff", "event_clock_code", "model_contract_version", "posterior_draw_set_id", "posterior_draw_count", "topic_count", "topic_ids", "topic_basis_sha256", "activity_intervals", "lineage_events", "document_relations", "plausible_values", "memberships", "inference_status"], "properties": { "schema_version": { "const": "tepp.topic_context_posterior.v1" }, "run_id": { "$ref": "#/$defs/identifier" }, @@ -18,6 +18,7 @@ "posterior_draw_count": { "$ref": "#/$defs/positive_u64" }, "topic_count": { "$ref": "#/$defs/topic_count" }, "topic_ids": { "type": "array", "minItems": 2, "maxItems": 1000000, "uniqueItems": true, "items": { "type": "string", "format": "uuid" } }, + "topic_basis_sha256": { "$ref": "#/$defs/digest" }, "activity_intervals": { "type": "array", "maxItems": 1000000, "items": { "$ref": "#/$defs/activity" } }, "lineage_events": { "type": "array", "maxItems": 1000000, "items": { "$ref": "#/$defs/lineage" } }, "document_relations": { "type": "array", "maxItems": 1000000, "items": { "$ref": "#/$defs/document_relation" } },