-
Notifications
You must be signed in to change notification settings - Fork 0
feat(event): persist interval consistency artifacts #291
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
seonghobae
merged 4 commits into
agent/event-interval-consistency-170
from
feat/interval-consistency-export-persistence
Aug 28, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ad44fd8
feat(event): persist interval consistency artifacts
seonghobae 17a91ce
fix(event): preserve reverse observation evidence
seonghobae 31e6bc5
fix(event): canonicalize artifact variable order
seonghobae 4f0223c
docs(event): clarify observation orientation
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,263 @@ | ||
| //! Durable, digest-bound export of bounded interval-consistency results. | ||
|
|
||
| use crate::{EventError, IntervalConsistencyNetwork}; | ||
| use serde::{Deserialize, Serialize}; | ||
| use sha2::{Digest, Sha256}; | ||
| use std::collections::BTreeSet; | ||
| use std::fmt::Write as _; | ||
| use temporal_core::{AllenRelation, RelationSet, TemporalVariableId}; | ||
|
|
||
| /// Model-artifact type used by the ADR-0013 persistence chain. | ||
| pub const INTERVAL_CONSISTENCY_ARTIFACT_TYPE: &str = "tdt_chronos_interval_consistency_v1"; | ||
| const SCHEMA_VERSION: &str = "tepp.tdt_chronos_interval_consistency.v1"; | ||
| const MAX_RELATIONS: usize = 100_000; | ||
| const MAX_JSON_BYTES: usize = 4 * 1024 * 1024; | ||
|
|
||
| /// One observed or closure-derived ordered interval relation. | ||
| #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] | ||
| #[serde(deny_unknown_fields)] | ||
| pub struct IntervalConsistencyArtifactRelation { | ||
| /// Opaque source event identity for the left interval. | ||
| pub left_event_id: String, | ||
| /// Opaque source event identity for the right interval. | ||
| pub right_event_id: String, | ||
| /// Remaining Allen relations in stable reasoner order. | ||
| pub allen_relations: Vec<AllenRelation>, | ||
| /// Whether either orientation of this interval pair has a direct accepted assertion. | ||
| pub observed: bool, | ||
| /// Accepted-assertion ordinals conservatively supporting this result. | ||
| pub support_assertion_ordinals: Vec<usize>, | ||
| } | ||
|
|
||
| /// Versioned bounded reasoner result suitable for immutable artifact storage. | ||
| #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] | ||
| #[serde(deny_unknown_fields)] | ||
| pub struct IntervalConsistencyArtifact { | ||
| /// Exact typed schema identity. | ||
| pub schema_version: String, | ||
| /// Opaque analysis-run identity. | ||
| pub run_id: String, | ||
| /// Immutable source snapshot identity. | ||
| pub snapshot_id: String, | ||
| /// Lowercase SHA-256 of the exact admitted input bytes. | ||
| pub input_digest_sha256: String, | ||
| /// Non-causal observed and closure-derived temporal relations. | ||
| pub relations: Vec<IntervalConsistencyArtifactRelation>, | ||
| } | ||
|
|
||
| impl IntervalConsistencyArtifact { | ||
| /// Project a closed network into a canonical artifact. | ||
| /// | ||
| /// Universal unconstrained pairs and identity pairs are omitted. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns a fail-closed wire or reasoner error for invalid bindings. | ||
| pub fn from_network( | ||
| run_id: impl Into<String>, | ||
| snapshot_id: impl Into<String>, | ||
| input_digest_sha256: impl Into<String>, | ||
| network: &IntervalConsistencyNetwork, | ||
| variables: &[(String, TemporalVariableId)], | ||
| ) -> Result<Self, EventError> { | ||
| let mut identities = BTreeSet::new(); | ||
| if variables.len() < 2 | ||
| || variables | ||
| .iter() | ||
| .any(|(identity, _)| identity.trim().is_empty() || !identities.insert(identity)) | ||
| { | ||
| return Err(EventError::InvalidWirePayload); | ||
| } | ||
| let mut ordered_variables = variables.iter().collect::<Vec<_>>(); | ||
| ordered_variables.sort_by(|left, right| left.0.cmp(&right.0)); | ||
| let mut relations = Vec::new(); | ||
| for (left_index, (left_identity, left)) in ordered_variables.iter().enumerate() { | ||
| for (right_identity, right) in ordered_variables.iter().skip(left_index + 1) { | ||
| let derived = network.derived_relation(*left, *right)?; | ||
| if derived.relations() == RelationSet::all() { | ||
| continue; | ||
| } | ||
| let inverse = network.derived_relation(*right, *left)?; | ||
| relations.push(IntervalConsistencyArtifactRelation { | ||
| left_event_id: left_identity.clone(), | ||
| right_event_id: right_identity.clone(), | ||
| allen_relations: derived.relations().iter().collect(), | ||
| // Observation is orientation-independent even though the | ||
| // export retains the stable variable ordering. | ||
| observed: derived.is_observed() || inverse.is_observed(), | ||
| support_assertion_ordinals: derived | ||
| .support() | ||
| .iter() | ||
| .map(|identifier| identifier.assertion_ordinal()) | ||
| .collect(), | ||
| }); | ||
|
seonghobae marked this conversation as resolved.
seonghobae marked this conversation as resolved.
|
||
| } | ||
| } | ||
| relations.sort_by(|left, right| { | ||
| (&left.left_event_id, &left.right_event_id) | ||
| .cmp(&(&right.left_event_id, &right.right_event_id)) | ||
| }); | ||
| let artifact = Self { | ||
| schema_version: SCHEMA_VERSION.to_owned(), | ||
| run_id: run_id.into(), | ||
| snapshot_id: snapshot_id.into(), | ||
| input_digest_sha256: input_digest_sha256.into(), | ||
| relations, | ||
| }; | ||
| artifact.validate()?; | ||
| let _canonical_json = artifact.to_json()?; | ||
| Ok(artifact) | ||
|
seonghobae marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /// Parse and validate canonical JSON. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns [`EventError::InvalidWirePayload`] for malformed input. | ||
| pub fn from_json(payload: &str) -> Result<Self, EventError> { | ||
| if payload.len() > MAX_JSON_BYTES { | ||
| return Err(EventError::InvalidWirePayload); | ||
| } | ||
| let artifact: Self = | ||
| serde_json::from_str(payload).map_err(|_| EventError::InvalidWirePayload)?; | ||
| artifact.validate()?; | ||
| if artifact.to_json()? != payload { | ||
| return Err(EventError::InvalidWirePayload); | ||
| } | ||
| Ok(artifact) | ||
| } | ||
|
seonghobae marked this conversation as resolved.
|
||
|
|
||
| /// Serialize canonical validated JSON. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns a wire error when fields or size are invalid. | ||
| pub fn to_json(&self) -> Result<String, EventError> { | ||
| self.validate()?; | ||
| let payload = serde_json::to_string(self).map_err(|_| EventError::InvalidWirePayload)?; | ||
| if payload.len() > MAX_JSON_BYTES { | ||
| return Err(EventError::InvalidWirePayload); | ||
| } | ||
| Ok(payload) | ||
| } | ||
|
|
||
| /// Return the lowercase SHA-256 of canonical JSON bytes. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns a wire error when the artifact is invalid. | ||
| pub fn sha256(&self) -> Result<String, EventError> { | ||
| Ok(format!("{:x}", Sha256::digest(self.to_json()?.as_bytes()))) | ||
| } | ||
|
|
||
| /// Render typed `GraphML` with observation and support provenance. | ||
| /// | ||
| /// # Errors | ||
| /// | ||
| /// Returns a wire error when the artifact is invalid. | ||
| pub fn to_graphml(&self) -> Result<String, EventError> { | ||
| self.validate()?; | ||
| let mut nodes = BTreeSet::new(); | ||
| for relation in &self.relations { | ||
| nodes.insert(&relation.left_event_id); | ||
| nodes.insert(&relation.right_event_id); | ||
| } | ||
| let mut output = String::from( | ||
| "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<graphml xmlns=\"http://graphml.graphdrawing.org/xmlns\">\n<key id=\"schema\" for=\"graph\" attr.name=\"schema_version\" attr.type=\"string\"/>\n<key id=\"snapshot\" for=\"graph\" attr.name=\"snapshot_id\" attr.type=\"string\"/>\n<key id=\"input_digest\" for=\"graph\" attr.name=\"input_digest_sha256\" attr.type=\"string\"/>\n<key id=\"relations\" for=\"edge\" attr.name=\"allen_relations\" attr.type=\"string\"/>\n<key id=\"observed\" for=\"edge\" attr.name=\"observed\" attr.type=\"boolean\"/>\n<key id=\"support\" for=\"edge\" attr.name=\"support_assertion_ordinals\" attr.type=\"string\"/>\n<graph id=\"", | ||
| ); | ||
| output.push_str(&xml_escape(&self.run_id)); | ||
| output.push_str("\" edgedefault=\"directed\">\n"); | ||
| writeln!( | ||
| output, | ||
| "<data key=\"schema\">{}</data><data key=\"snapshot\">{}</data><data key=\"input_digest\">{}</data>", | ||
| xml_escape(&self.schema_version), | ||
| xml_escape(&self.snapshot_id), | ||
| self.input_digest_sha256 | ||
| ) | ||
| .expect("writing to String cannot fail"); | ||
| for node in nodes { | ||
| output.push_str("<node id=\""); | ||
| output.push_str(&xml_escape(node)); | ||
| output.push_str("\"/>\n"); | ||
| } | ||
| for (index, relation) in self.relations.iter().enumerate() { | ||
| append_edge(&mut output, index, relation); | ||
| } | ||
| output.push_str("</graph>\n</graphml>\n"); | ||
| Ok(output) | ||
| } | ||
|
|
||
| fn validate(&self) -> Result<(), EventError> { | ||
| if self.schema_version != SCHEMA_VERSION | ||
| || self.run_id.trim().is_empty() | ||
| || self.snapshot_id.trim().is_empty() | ||
| || !valid_digest(&self.input_digest_sha256) | ||
| || self.relations.is_empty() | ||
| || self.relations.len() > MAX_RELATIONS | ||
| { | ||
| return Err(EventError::InvalidWirePayload); | ||
| } | ||
| let mut previous = None; | ||
| for relation in &self.relations { | ||
| let key = (&relation.left_event_id, &relation.right_event_id); | ||
| if relation.left_event_id.trim().is_empty() | ||
| || relation.right_event_id.trim().is_empty() | ||
| || relation.left_event_id == relation.right_event_id | ||
| || relation.allen_relations.is_empty() | ||
| || relation.allen_relations.len() == AllenRelation::ALL.len() | ||
| || relation.support_assertion_ordinals.is_empty() | ||
| || !strictly_increasing(&relation.allen_relations) | ||
| || !strictly_increasing(&relation.support_assertion_ordinals) | ||
| || previous.is_some_and(|old| old >= key) | ||
| { | ||
| return Err(EventError::InvalidWirePayload); | ||
| } | ||
| previous = Some(key); | ||
| } | ||
| Ok(()) | ||
| } | ||
|
seonghobae marked this conversation as resolved.
|
||
| } | ||
|
|
||
| fn append_edge(output: &mut String, index: usize, relation: &IntervalConsistencyArtifactRelation) { | ||
| let kinds = relation | ||
| .allen_relations | ||
| .iter() | ||
| .map(|value| serde_json::to_string(value).expect("Allen relation serialization")) | ||
| .map(|value| value.trim_matches('"').to_owned()) | ||
| .collect::<Vec<_>>() | ||
| .join(","); | ||
| let support = relation | ||
| .support_assertion_ordinals | ||
| .iter() | ||
| .map(usize::to_string) | ||
| .collect::<Vec<_>>() | ||
| .join(","); | ||
| writeln!( | ||
| output, | ||
| "<edge id=\"e{index}\" source=\"{}\" target=\"{}\"><data key=\"relations\">{}</data><data key=\"observed\">{}</data><data key=\"support\">{support}</data></edge>", | ||
| xml_escape(&relation.left_event_id), | ||
| xml_escape(&relation.right_event_id), | ||
| xml_escape(&kinds), | ||
| relation.observed | ||
| ) | ||
| .expect("writing to String cannot fail"); | ||
| } | ||
|
|
||
| fn strictly_increasing<T: Ord>(values: &[T]) -> bool { | ||
| values.windows(2).all(|pair| pair[0] < pair[1]) | ||
| } | ||
|
|
||
| fn valid_digest(value: &str) -> bool { | ||
| value.len() == 64 | ||
| && value | ||
| .bytes() | ||
| .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) | ||
| } | ||
|
|
||
| fn xml_escape(value: &str) -> String { | ||
| value | ||
| .replace('&', "&") | ||
| .replace('<', "<") | ||
| .replace('>', ">") | ||
| .replace('"', """) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.