From 346fb24692f605951ec384b823072780bddee1ad Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 05:20:54 +0300 Subject: [PATCH] Implement closed typed semantic review with immutable proposals --- crates/oa-core/examples/routing_workshop.rs | 1 + crates/oa-core/src/demo.rs | 1 + crates/oa-core/src/lib.rs | 2 + crates/oa-core/src/model.rs | 8 +- crates/oa-core/src/references.rs | 7 + crates/oa-core/src/semantic.rs | 16 + crates/oa-core/src/semantic/catalog.rs | 1131 ++++++++++ crates/oa-core/src/semantic/historical.rs | 404 ++++ crates/oa-core/src/semantic/linked.rs | 174 ++ crates/oa-core/src/semantic/mutate.rs | 592 +++++ crates/oa-core/src/semantic/review.rs | 1135 ++++++++++ crates/oa-core/src/semantic/types.rs | 255 +++ crates/oa-core/src/transitions.rs | 14 +- crates/oa-core/src/validation.rs | 13 + crates/oa-core/tests/r3_semantic.rs | 1919 +++++++++++++++++ crates/oa-core/tests/workflows.rs | 1 + harness/DECISIONS.md | 33 + harness/STATE.md | 77 +- .../release-semantic-review-implementation.md | 80 + 19 files changed, 5855 insertions(+), 8 deletions(-) create mode 100644 crates/oa-core/src/semantic.rs create mode 100644 crates/oa-core/src/semantic/catalog.rs create mode 100644 crates/oa-core/src/semantic/historical.rs create mode 100644 crates/oa-core/src/semantic/linked.rs create mode 100644 crates/oa-core/src/semantic/mutate.rs create mode 100644 crates/oa-core/src/semantic/review.rs create mode 100644 crates/oa-core/src/semantic/types.rs create mode 100644 crates/oa-core/tests/r3_semantic.rs diff --git a/crates/oa-core/examples/routing_workshop.rs b/crates/oa-core/examples/routing_workshop.rs index 90bd395a..8d02fa13 100644 --- a/crates/oa-core/examples/routing_workshop.rs +++ b/crates/oa-core/examples/routing_workshop.rs @@ -507,6 +507,7 @@ fn workshop() -> Project { let baseline = TraceBaseline { oa: p.properties(t.oa).unwrap(), sa: p.properties(t.sa).unwrap(), + semantic: None, }; p.traces.get_mut(&id).unwrap().baseline = Some(baseline); } diff --git a/crates/oa-core/src/demo.rs b/crates/oa-core/src/demo.rs index fb4e1e7d..092500e6 100644 --- a/crates/oa-core/src/demo.rs +++ b/crates/oa-core/src/demo.rs @@ -111,6 +111,7 @@ fn trace(p: &mut Project, oa: Id, sa: Id, kind: TraceKind) { baseline: Some(TraceBaseline { oa: p.properties(oa).unwrap(), sa: p.properties(sa).unwrap(), + semantic: None, }), }, ); diff --git a/crates/oa-core/src/lib.rs b/crates/oa-core/src/lib.rs index fa53961a..e8aadf25 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod ports; pub mod projection; pub mod references; pub mod routing; +pub mod semantic; pub mod session; pub mod source_files; pub mod source_project; @@ -14,6 +15,7 @@ pub mod transitions; pub mod validation; pub use model::*; pub use ports::*; +pub use semantic::*; pub use session::*; mod demo; mod state_validation; diff --git a/crates/oa-core/src/model.rs b/crates/oa-core/src/model.rs index de6f4b6f..868a3b46 100644 --- a/crates/oa-core/src/model.rs +++ b/crates/oa-core/src/model.rs @@ -6,7 +6,7 @@ use std::{ use uuid::Uuid; pub type Id = Uuid; -pub const SCHEMA_VERSION: u32 = 4; +pub const SCHEMA_VERSION: u32 = 5; pub const MAX_COORDINATE: f32 = 1_000_000.; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -1492,13 +1492,15 @@ pub struct TracedProperties { pub name: String, pub description: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct TraceBaseline { pub oa: TracedProperties, pub sa: TracedProperties, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub semantic: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Trace { pub id: Id, diff --git a/crates/oa-core/src/references.rs b/crates/oa-core/src/references.rs index 757a3fd0..91721df7 100644 --- a/crates/oa-core/src/references.rs +++ b/crates/oa-core/src/references.rs @@ -222,6 +222,13 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) { if t.sa == old { t.sa = new; } + if let Some(baseline) = t.baseline.as_mut().and_then(|b| b.semantic.as_mut()) { + crate::semantic::visit_semantic_references(baseline, |id| { + if *id == old { + *id = new; + } + }); + } } } /// IDs removed by a cascade. Cross-layer traced counterparts are never included. diff --git a/crates/oa-core/src/semantic.rs b/crates/oa-core/src/semantic.rs new file mode 100644 index 00000000..517a1854 --- /dev/null +++ b/crates/oa-core/src/semantic.rs @@ -0,0 +1,16 @@ +//! Closed typed facts for deliberately reviewed OA/SA semantics. +//! +//! Historical facts are independently acknowledged observations, not a synthetic +//! live model. Their UUIDs are not live references and survive ordinary deletion. +mod catalog; +mod historical; +mod linked; +mod mutate; +mod review; +mod types; +pub use catalog::*; +pub(crate) use historical::{HistoricalSourceKind, historical_source_slots}; +pub use historical::{validate_semantic_baseline, visit_semantic_references}; +pub use linked::*; +pub use review::*; +pub use types::*; diff --git a/crates/oa-core/src/semantic/catalog.rs b/crates/oa-core/src/semantic/catalog.rs new file mode 100644 index 00000000..63cb4c27 --- /dev/null +++ b/crates/oa-core/src/semantic/catalog.rs @@ -0,0 +1,1131 @@ +use super::*; +use crate::model::*; +use crate::transitions::Drift; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +/// Complete transient typed record; never used as a persisted historical carrier. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub enum SemanticRecord { + Element(Element), + Relation(Relation), + Artifact(ArtifactLink), +} +impl SemanticRecord { + pub fn id(&self) -> Id { + match self { + Self::Element(e) => e.id, + Self::Relation(r) => r.id, + Self::Artifact(a) => a.id, + } + } +} +pub fn semantic_record(project: &Project, id: Id) -> Option { + project + .elements + .get(&id) + .cloned() + .map(SemanticRecord::Element) + .or_else(|| { + project + .relations + .get(&id) + .cloned() + .map(SemanticRecord::Relation) + }) + .or_else(|| { + project + .artifact_links + .get(&id) + .cloned() + .map(SemanticRecord::Artifact) + }) +} + +// Private serde access is a fixed adapter to existing typed records. The public +// selector fixes each leaf's Rust type; no user path or untyped value is accepted. +struct Own { + whole: Value, + prefix: Vec, + kind: String, +} +fn enum_tag(value: &Value) -> Option<&str> { + value.as_str().or_else(|| { + value + .as_object() + .and_then(|v| v.keys().next().map(String::as_str)) + }) +} +impl Own { + fn get(project: &Project, id: Id) -> Option { + let (whole, element) = if let Some(e) = project.elements.get(&id) { + (serde_json::to_value(e).ok()?, true) + } else { + ( + serde_json::to_value(project.relations.get(&id)?).ok()?, + false, + ) + }; + let mut prefix = vec!["body".to_string()]; + let mut value = &whole["body"]; + if element { + let layer = enum_tag(value)?.to_string(); + prefix.push(layer.clone()); + value = &value[&layer]; + if enum_tag(value) == Some("Shared") { + prefix.push("Shared".into()); + value = &value["Shared"]; + } + } + let kind = enum_tag(value)?.to_string(); + prefix.push(kind.clone()); + Some(Self { + whole, + prefix, + kind, + }) + } +} +fn at<'a>(mut value: &'a Value, path: &[String]) -> Option<&'a Value> { + for part in path { + value = value.get(part)?; + } + Some(value) +} +fn at_mut<'a>(mut value: &'a mut Value, path: &[String]) -> Option<&'a mut Value> { + for part in path { + value = value.get_mut(part)?; + } + Some(value) +} +fn path(own: &Own, facet: &FacetSelector) -> Option> { + use FacetSelector as F; + let (family, parts): (&str, &[&str]) = match facet { + F::Header(HeaderFacet::Name) => return Some(vec!["name".into()]), + F::Header(HeaderFacet::Description) => return Some(vec!["description".into()]), + F::Header(HeaderFacet::Parent) => { + return own.whole.get("parent").map(|_| vec!["parent".into()]); + } + F::ExchangeItem(ExchangeItemFacet::Nature) => ("ExchangeItem", &["kind"]), + F::ExchangeItem(ExchangeItemFacet::Mechanism) => ("ExchangeItem", &["mechanism"]), + F::ExchangeItem(ExchangeItemFacet::Supertype) => ("ExchangeItem", &["supertype"]), + F::Interface(InterfaceFacet::Supertype) => ("Interface", &["supertype"]), + F::Data(DataFacet::Constructor) => ("DataType", &["definition"]), + F::Data(DataFacet::IntegerMinimum) => ("DataType", &["definition", "Integer", "minimum"]), + F::Data(DataFacet::IntegerMaximum) => ("DataType", &["definition", "Integer", "maximum"]), + F::Data(DataFacet::RealMinimum) => ("DataType", &["definition", "Real", "minimum"]), + F::Data(DataFacet::RealMaximum) => ("DataType", &["definition", "Real", "maximum"]), + F::Data(DataFacet::StringMinimumLength) => { + ("DataType", &["definition", "String", "minimum_length"]) + } + F::Data(DataFacet::StringMaximumLength) => { + ("DataType", &["definition", "String", "maximum_length"]) + } + F::Data(DataFacet::RecordSupertype) => ("DataType", &["definition", "Record", "supertype"]), + F::Data(DataFacet::Default) => ("DataType", &["default"]), + F::Data(DataFacet::Unit) => ("DataType", &["unit"]), + F::Field(FieldFacet::Type) => ("DataField", &["type_ref"]), + F::Field(FieldFacet::Minimum) => ("DataField", &["minimum"]), + F::Field(FieldFacet::Maximum) => ("DataField", &["maximum"]), + F::Field(FieldFacet::Unit) => ("DataField", &["unit"]), + F::Field(FieldFacet::Direction) => ("DataField", &["direction"]), + F::Field(FieldFacet::Storage) => ("DataField", &["storage"]), + F::Field(FieldFacet::Default) => ("DataField", &["default"]), + F::Port(PortFacet::Constructor) => ("Port", &["contract"]), + F::Port(PortFacet::ComponentKind) => ("Port", &["contract", "Component", "kind"]), + F::Port(PortFacet::Orientation) => ("Port", &["contract", "Component", "orientation"]), + F::Port(PortFacet::LegacyDirection) => ("Port", &["legacy", "direction"]), + F::Port(PortFacet::LegacyInterface) => ("Port", &["legacy", "interface"]), + F::Scenario(ScenarioFacet::Viewpoint) => ("Scenario", &["viewpoint"]), + F::Scenario(ScenarioFacet::Context) => ("Scenario", &["context"]), + F::Scenario(ScenarioFacet::Precondition) => ("Scenario", &["precondition"]), + F::Scenario(ScenarioFacet::Postcondition) => ("Scenario", &["postcondition"]), + F::Lifeline(LifelineFacet::Represented) => ("Lifeline", &["represented"]), + F::Occurrence(OccurrenceFacet::Constructor) => ("Occurrence", &["body"]), + F::Occurrence(OccurrenceFacet::Precondition) => ("Occurrence", &["precondition"]), + F::Occurrence(OccurrenceFacet::Postcondition) => ("Occurrence", &["postcondition"]), + F::Occurrence(OccurrenceFacet::Exchange) => { + ("Occurrence", &["body", "Exchange", "exchange"]) + } + F::Occurrence(OccurrenceFacet::Sender) => ("Occurrence", &["body", "Exchange", "sender"]), + F::Occurrence(OccurrenceFacet::Receiver) => { + ("Occurrence", &["body", "Exchange", "receiver"]) + } + F::Occurrence(OccurrenceFacet::MessageKind) => { + ("Occurrence", &["body", "Exchange", "message_kind"]) + } + F::Occurrence(OccurrenceFacet::TimerExpression) => ( + "Occurrence", + &["body", "Exchange", "message_kind", "Timer", "expression"], + ), + F::Occurrence(OccurrenceFacet::ReplyTo) => { + ("Occurrence", &["body", "Exchange", "reply_to"]) + } + F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]), + F::Occurrence(OccurrenceFacet::ActionLifeline) => { + ("Occurrence", &["body", "Action", "lifeline"]) + } + F::Occurrence(OccurrenceFacet::ControlKind) => ("Occurrence", &["body", "Control", "kind"]), + F::Occurrence(OccurrenceFacet::Sequence) => { + ("Occurrence", &["body", "SequenceReference", "sequence"]) + } + F::Occurrence(OccurrenceFacet::Scenario) => { + ("Occurrence", &["body", "ScenarioUse", "scenario"]) + } + F::Occurrence(OccurrenceFacet::State) => ("Occurrence", &["body", "State", "state"]), + F::Occurrence(OccurrenceFacet::StateLifeline) => { + ("Occurrence", &["body", "State", "lifeline"]) + } + F::Occurrence(OccurrenceFacet::FragmentOperator) => { + ("Occurrence", &["body", "Fragment", "operator"]) + } + F::Occurrence(OccurrenceFacet::BranchGuard) => ("Occurrence", &["body", "Branch", "guard"]), + F::Event(EventFacet::Constructor) => ("Event", &["body"]), + F::Event(EventFacet::TimeKind) => ("Event", &["body", "Time", "kind"]), + F::Event(EventFacet::TimeExpression) => ("Event", &["body", "Time", "expression"]), + F::Event(EventFacet::ChangeExpression) => ("Event", &["body", "Change", "expression"]), + F::State(StateFacet::Kind) => ("State", &["kind"]), + F::State(StateFacet::Entry) => ("State", &["entry"]), + F::State(StateFacet::Exit) => ("State", &["exit"]), + F::State(StateFacet::DoActivity) => ("State", &["do_activity"]), + F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]), + F::Relation(RelationFacet::Source) => ("*", &["source"]), + F::Relation(RelationFacet::Target) => ("*", &["target"]), + F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]), + F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]), + F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]), + F::Relation(RelationFacet::SourceRole) => ("DataAssociation", &["source_role"]), + F::Relation(RelationFacet::TargetRole) => ("DataAssociation", &["target_role"]), + F::Relation(RelationFacet::SourceMinimum) => ("DataAssociation", &["source_minimum"]), + F::Relation(RelationFacet::SourceMaximum) => ("DataAssociation", &["source_maximum"]), + F::Relation(RelationFacet::TargetMinimum) => ("DataAssociation", &["target_minimum"]), + F::Relation(RelationFacet::TargetMaximum) => ("DataAssociation", &["target_maximum"]), + F::Relation(RelationFacet::SourceNavigable) => ("DataAssociation", &["source_navigable"]), + F::Relation(RelationFacet::TargetNavigable) => ("DataAssociation", &["target_navigable"]), + F::Relation(RelationFacet::SourcePort) => ("FunctionalExchange", &["source_port"]), + F::Relation(RelationFacet::TargetPort) => ("FunctionalExchange", &["target_port"]), + F::Relation(RelationFacet::Oriented) => ("BoundaryExchange", &["oriented"]), + F::Relation(RelationFacet::ExtendCondition) => ("CapabilityExtend", &["condition"]), + F::Relation(RelationFacet::SequenceGuard) => ("SequenceLink", &["guard"]), + F::Relation(RelationFacet::TransitionKind) => ("StateTransition", &["transition_kind"]), + F::Relation(RelationFacet::Trigger) => ("StateTransition", &["trigger"]), + F::Relation(RelationFacet::Guard) => ("StateTransition", &["guard"]), + F::Relation(RelationFacet::Effect) => ("StateTransition", &["effect"]), + F::Relation(RelationFacet::StateActionPhase) => ("StateAction", &["phase"]), + _ => return None, + }; + if family != "*" && own.kind != family { + return None; + } + let mut path = own.prefix.clone(); + path.extend(parts.iter().map(|s| s.to_string())); + at(&own.whole, &path).map(|_| path) +} +pub(crate) fn is_constructor(facet: &FacetSelector) -> bool { + matches!( + facet, + FacetSelector::Data(DataFacet::Constructor) + | FacetSelector::Port(PortFacet::Constructor) + | FacetSelector::Occurrence( + OccurrenceFacet::Constructor | OccurrenceFacet::MessageKind + ) + | FacetSelector::Event(EventFacet::Constructor) + | FacetSelector::State(StateFacet::Kind) + ) +} +fn scalar_value(project: &Project, id: Id, facet: &FacetSelector) -> Option { + let own = Own::get(project, id)?; + let path = path(&own, facet)?; + let value = at(&own.whole, &path)?; + if is_constructor(facet) { + Some(Value::String(enum_tag(value)?.to_string())) + } else { + Some(value.clone()) + } +} +fn decode(value: Option) -> Result, String> { + match value { + None => Ok(Observed::NotApplicable), + Some(v) => serde_json::from_value(v) + .map(Observed::Value) + .map_err(|e| e.to_string()), + } +} +fn pair(oa: Option, sa: Option) -> Result, String> { + Ok(Reviewed { + oa: decode(oa)?, + sa: decode(sa)?, + }) +} +fn observation(value: Option) -> Observed { + value + .map(Observed::Value) + .unwrap_or(Observed::NotApplicable) +} + +pub(crate) fn collection(project: &Project, id: Id, which: ReferenceCollection) -> Option> { + use ReferenceCollection as C; + let element = project.elements.get(&id); + let shared = element.and_then(Element::shared); + match which { + C::Steps => element + .and_then(Element::steps) + .cloned() + .or_else(|| match shared { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { occurrences, .. }, + .. + }) => Some(occurrences.clone()), + _ => None, + }), + C::FragmentBranches => match shared { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + }) => Some(branches.clone()), + _ => None, + }, + C::FunctionInputItems => match shared { + Some(SharedBody::Port { + contract: PortContract::FunctionInput { incoming_items }, + .. + }) => Some(incoming_items.clone()), + _ => None, + }, + C::FunctionOutputItems => match shared { + Some(SharedBody::Port { + contract: PortContract::FunctionOutput { outgoing_items }, + .. + }) => Some(outgoing_items.clone()), + _ => None, + }, + C::SignalItems => match shared { + Some(SharedBody::Event { + body: EventBody::Signal { items }, + }) => Some(items.clone()), + _ => None, + }, + C::ExchangeItems => match project.relations.get(&id).map(|r| &r.body) { + Some( + RelationBody::Interaction { items, .. } + | RelationBody::FunctionalExchange { items, .. } + | RelationBody::BoundaryExchange { items, .. }, + ) => Some(items.clone()), + _ => None, + }, + C::TransitionTriggers => match project.relations.get(&id).map(|r| &r.body) { + Some(RelationBody::StateTransition { triggers, .. }) => Some(triggers.clone()), + _ => None, + }, + C::TransitionEffects => match project.relations.get(&id).map(|r| &r.body) { + Some(RelationBody::StateTransition { effects, .. }) => Some(effects.clone()), + _ => None, + }, + C::Constrained => match shared { + Some(SharedBody::Constraint { constrained, .. }) => Some(constrained.clone()), + _ => None, + }, + } +} +pub(crate) fn order_collection(which: OrderedCollection) -> ReferenceCollection { + match which { + OrderedCollection::Steps => ReferenceCollection::Steps, + OrderedCollection::FragmentBranches => ReferenceCollection::FragmentBranches, + } +} +fn member( + project: &Project, + id: Id, + collection_kind: ReferenceCollection, + key: Option, +) -> Observed { + observation( + collection(project, id, collection_kind).map(|list| key.is_some_and(|k| list.contains(&k))), + ) +} +fn literals(project: &Project, id: Id) -> Option<&Vec> { + match project.elements.get(&id)?.shared()? { + SharedBody::DataType { + definition: DataTypeDefinition::Enumeration { literals }, + .. + } => Some(literals), + _ => None, + } +} +fn annotations(project: &Project, id: Id) -> Option<&Vec> { + match project.elements.get(&id)?.shared()? { + SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + } => Some(values), + _ => None, + } +} +fn bindings(project: &Project, id: Id) -> Option<&Vec> { + match project.elements.get(&id)?.shared()? { + SharedBody::Occurrence { + body: OccurrenceBody::ScenarioUse { bindings, .. }, + .. + } => Some(bindings), + _ => None, + } +} +fn artifact_value( + project: &Project, + owner: Id, + key: Option, + facet: ArtifactFacet, +) -> Option { + if !project.elements.contains_key(&owner) && !project.relations.contains_key(&owner) { + return None; + } + let artifact = key.and_then(|k| project.artifact_links.get(&k)); + if facet == ArtifactFacet::Presence { + return Some(Value::Bool(artifact.is_some_and(|a| a.owner == owner))); + } + // Metadata describes this identified record even after an explicit owner + // move. New decisions separately authorize its initial attachment; historic + // observations must retain the actual resulting owner instead of both NA. + let a = serde_json::to_value(artifact?).ok()?; + let field = match facet { + ArtifactFacet::Presence => unreachable!(), + ArtifactFacet::Name => "name", + ArtifactFacet::Description => "description", + ArtifactFacet::Owner => "owner", + ArtifactFacet::Relation => "relation", + ArtifactFacet::Uri => "uri", + ArtifactFacet::Revision => "revision", + ArtifactFacet::Fragment => "fragment", + }; + a.get(field).cloned() +} +macro_rules! capture_families { + ($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => { + /// Capture one typed pair in canonical OA/SA coordinates. Applicability is + /// checked here, including Keep operations; NA is never a wrong-family escape. + pub(crate) fn capture_observation(project: &Project, oa: Id, sa: Id, selector: &FacetSelector) -> Result { + let result = match selector { + $( FacetSelector::$family(facet) => FacetReview::$family(match facet { + $( $selector::$leaf => $review::$leaf(pair::<$ty>(scalar_value(project,oa,selector),scalar_value(project,sa,selector))?), )* + }), )* + FacetSelector::Member { collection, keys } => FacetReview::Member { collection: *collection, keys: keys.clone(), review: Reviewed { oa: member(project,oa,*collection,keys.oa), sa: member(project,sa,*collection,keys.sa) } }, + FacetSelector::Order(which) => FacetReview::Order { collection: *which, review: Reviewed { oa: observation(collection(project,oa,order_collection(*which))), sa: observation(collection(project,sa,order_collection(*which))) } }, + FacetSelector::Literal(keys) => FacetReview::Literal { keys: keys.clone(), review: Reviewed { + oa: observation(literals(project,oa).map(|list| keys.oa.as_ref().is_some_and(|k| list.contains(k)))), + sa: observation(literals(project,sa).map(|list| keys.sa.as_ref().is_some_and(|k| list.contains(k)))) } }, + FacetSelector::Annotation(keys) => FacetReview::Annotation { keys: keys.clone(), review: Reviewed { + oa: observation(annotations(project,oa).map(|list| list.iter().find(|v| Some(&AnnotationKey::from(*v)) == keys.oa.as_ref()).cloned())), + sa: observation(annotations(project,sa).map(|list| list.iter().find(|v| Some(&AnnotationKey::from(*v)) == keys.sa.as_ref()).cloned())) } }, + FacetSelector::Binding(keys) => FacetReview::Binding { keys: keys.clone(), review: Reviewed { + oa: observation(bindings(project,oa).map(|list| list.iter().find(|v| Some(v.referenced) == keys.oa).cloned())), + sa: observation(bindings(project,sa).map(|list| list.iter().find(|v| Some(v.referenced) == keys.sa).cloned())) } }, + FacetSelector::Child(keys) => FacetReview::Child { keys: keys.clone(), review: Reviewed { + oa: observation(project.elements.contains_key(&oa).then(|| keys.oa.and_then(|k| project.elements.get(&k)).is_some_and(|e| e.parent == Some(oa)))), + sa: observation(project.elements.contains_key(&sa).then(|| keys.sa.and_then(|k| project.elements.get(&k)).is_some_and(|e| e.parent == Some(sa)))) } }, + FacetSelector::Incident { keys, end } => { let exists = |owner, key: Option| observation(project.elements.contains_key(&owner).then(|| key.and_then(|k| project.relations.get(&k)).is_some_and(|r| { + let endpoints = r.body.endpoints(); match end { IncidentEnd::Source => endpoints.0 == owner, IncidentEnd::Target => endpoints.1 == owner } + }))); FacetReview::Incident { keys: keys.clone(), end: *end, review: Reviewed { oa: exists(oa,keys.oa), sa: exists(sa,keys.sa) } } }, + FacetSelector::Artifact { keys, facet } => { + let a = artifact_value(project,oa,keys.oa,*facet); let b = artifact_value(project,sa,keys.sa,*facet); + let review = match facet { + ArtifactFacet::Presence => ArtifactReview::Presence(pair(a,b)?), ArtifactFacet::Name => ArtifactReview::Name(pair(a,b)?), + ArtifactFacet::Description => ArtifactReview::Description(pair(a,b)?), ArtifactFacet::Owner => ArtifactReview::Owner(pair(a,b)?), + ArtifactFacet::Relation => ArtifactReview::Relation(pair(a,b)?), ArtifactFacet::Uri => ArtifactReview::Uri(pair(a,b)?), + ArtifactFacet::Revision => ArtifactReview::Revision(pair(a,b)?), ArtifactFacet::Fragment => ArtifactReview::Fragment(pair(a,b)?), + }; FacetReview::Artifact { keys: keys.clone(), review } + } + }; + Ok(result) + } + pub(crate) fn sides(facet: &FacetReview) -> (Value,Value) { + fn values(r: &Reviewed) -> (Value,Value) { (serde_json::to_value(&r.oa).unwrap(),serde_json::to_value(&r.sa).unwrap()) } + match facet { + $( FacetReview::$family(facet) => match facet { $( $review::$leaf(v) => values(v), )* }, )* + FacetReview::Member { review, .. } | FacetReview::Literal { review, .. } | FacetReview::Child { review, .. } | FacetReview::Incident { review, .. } => values(review), + FacetReview::Order { review, .. } => values(review), FacetReview::Annotation { review, .. } => values(review), FacetReview::Binding { review, .. } => values(review), + FacetReview::Artifact { review, .. } => match review { + ArtifactReview::Presence(v) => values(v), ArtifactReview::Name(v) | ArtifactReview::Description(v) | ArtifactReview::Uri(v) => values(v), + ArtifactReview::Owner(v) => values(v), ArtifactReview::Relation(v) => values(v), ArtifactReview::Revision(v) | ArtifactReview::Fragment(v) => values(v), + } + } + } + pub(crate) fn replace_observation(mut fact:FacetReview,layer:Layer,raw:Value)->Result { + fn replace(review:&mut Reviewed,layer:Layer,raw:Value)->Result<(),String> { + let value=serde_json::from_value(raw).map_err(|e|e.to_string())?; + match layer {Layer::Oa=>review.oa=value,Layer::Sa=>review.sa=value};Ok(()) + } + match &mut fact { + $( FacetReview::$family(facet)=>match facet { $( $review::$leaf(v)=>replace(v,layer,raw)?, )* }, )* + FacetReview::Member{review,..}|FacetReview::Literal{review,..}|FacetReview::Child{review,..}|FacetReview::Incident{review,..}=>replace(review,layer,raw)?, + FacetReview::Order{review,..}=>replace(review,layer,raw)?,FacetReview::Annotation{review,..}=>replace(review,layer,raw)?,FacetReview::Binding{review,..}=>replace(review,layer,raw)?, + FacetReview::Artifact{review,..}=>match review { + ArtifactReview::Presence(v)=>replace(v,layer,raw)?,ArtifactReview::Name(v)|ArtifactReview::Description(v)|ArtifactReview::Uri(v)=>replace(v,layer,raw)?, + ArtifactReview::Owner(v)=>replace(v,layer,raw)?,ArtifactReview::Relation(v)=>replace(v,layer,raw)?,ArtifactReview::Revision(v)|ArtifactReview::Fragment(v)=>replace(v,layer,raw)?, + } + };Ok(fact) + } + fn scalar_selectors() -> Vec { vec![ $( $( FacetSelector::$family($selector::$leaf), )* )* ] } + }; +} +types::scalar_families!(capture_families); + +pub fn capture_facet( + project: &Project, + oa: Id, + sa: Id, + selector: &FacetSelector, +) -> Result { + let fact = capture_observation(project, oa, sa, selector)?; + validate_semantic_baseline(&SemanticBaseline { + facets: vec![fact.clone()], + }) + .map_err(|e| e.join("; "))?; + Ok(fact) +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FacetDrift { + pub selector: FacetSelector, + pub current: FacetReview, + pub drift: Drift, +} +pub fn facet_drift(current: &FacetReview, baseline: Option<&FacetReview>) -> Drift { + let Some(base) = baseline.filter(|b| b.selector() == current.selector()) else { + return Drift::Unreviewed; + }; + let (oa, sa) = sides(current); + let (old_oa, old_sa) = sides(base); + match (oa != old_oa, sa != old_sa) { + (false, false) => Drift::Unchanged, + (true, false) => Drift::OaChanged, + (false, true) => Drift::SaChanged, + (true, true) => Drift::BothChanged, + } +} + +fn keys( + oa: impl IntoIterator, + sa: impl IntoIterator, + map: impl Fn(&T) -> Option, +) -> Vec> { + let oa: BTreeSet<_> = oa.into_iter().collect(); + let sa: BTreeSet<_> = sa.into_iter().collect(); + let mut used = BTreeSet::new(); + let mut result = vec![]; + for key in oa { + let target = map(&key); + if let Some(key) = &target { + used.insert(key.clone()); + } + result.push(KeyPair { + oa: Some(key), + sa: target, + }); + } + for key in sa.difference(&used) { + result.push(KeyPair { + oa: None, + sa: Some(key.clone()), + }); + } + result +} +/// Enumerate own and attached semantic facts using explicit OA→SA correspondence. +/// Historical selectors are retained so removals/deleted references remain visible. +pub fn semantic_facets( + project: &Project, + trace: &Trace, + references: &BTreeMap, +) -> Result, String> { + catalog_facets(project, trace, references, true) +} +pub(super) fn own_semantic_facets( + project: &Project, + trace: &Trace, + references: &BTreeMap, +) -> Result, String> { + catalog_facets(project, trace, references, false) +} +fn linked_selector(selector: &FacetSelector) -> bool { + matches!( + selector, + FacetSelector::Child(_) | FacetSelector::Incident { .. } | FacetSelector::Artifact { .. } + ) +} +fn catalog_facets( + project: &Project, + trace: &Trace, + references: &BTreeMap, + include_linked: bool, +) -> Result, String> { + let (oa, sa) = (trace.oa, trace.sa); + let mut selectors: BTreeSet<_> = scalar_selectors() + .into_iter() + .filter(|s| { + scalar_value(project, oa, s).is_some() || scalar_value(project, sa, s).is_some() + }) + .collect(); + for which in [ + ReferenceCollection::Steps, + ReferenceCollection::FragmentBranches, + ReferenceCollection::FunctionInputItems, + ReferenceCollection::FunctionOutputItems, + ReferenceCollection::SignalItems, + ReferenceCollection::ExchangeItems, + ReferenceCollection::TransitionTriggers, + ReferenceCollection::TransitionEffects, + ReferenceCollection::Constrained, + ] { + for key in keys( + collection(project, oa, which).unwrap_or_default(), + collection(project, sa, which).unwrap_or_default(), + |id| references.get(id).copied(), + ) { + selectors.insert(FacetSelector::Member { + collection: which, + keys: key, + }); + } + } + for which in [ + OrderedCollection::Steps, + OrderedCollection::FragmentBranches, + ] { + if collection(project, oa, order_collection(which)).is_some() + || collection(project, sa, order_collection(which)).is_some() + { + selectors.insert(FacetSelector::Order(which)); + } + } + for key in keys( + literals(project, oa).cloned().unwrap_or_default(), + literals(project, sa).cloned().unwrap_or_default(), + |key| Some(key.clone()), + ) { + selectors.insert(FacetSelector::Literal(key)); + } + for key in keys( + annotations(project, oa) + .into_iter() + .flatten() + .map(AnnotationKey::from), + annotations(project, sa) + .into_iter() + .flatten() + .map(AnnotationKey::from), + |key| { + Some(AnnotationKey { + path: key + .path + .iter() + .map(|id| references.get(id).copied()) + .collect::>()?, + field: *references.get(&key.field)?, + }) + }, + ) { + selectors.insert(FacetSelector::Annotation(key)); + } + for key in keys( + bindings(project, oa) + .into_iter() + .flatten() + .map(|v| v.referenced), + bindings(project, sa) + .into_iter() + .flatten() + .map(|v| v.referenced), + |id| references.get(id).copied(), + ) { + selectors.insert(FacetSelector::Binding(key)); + } + if include_linked { + for key in keys( + project + .elements + .values() + .filter(|e| e.parent == Some(oa)) + .map(|e| e.id), + project + .elements + .values() + .filter(|e| e.parent == Some(sa)) + .map(|e| e.id), + |id| references.get(id).copied(), + ) { + selectors.insert(FacetSelector::Child(key)); + } + for end in [IncidentEnd::Source, IncidentEnd::Target] { + let incident = |owner| { + project + .relations + .values() + .filter(move |r| { + let (a, b) = r.body.endpoints(); + match end { + IncidentEnd::Source => a == owner, + IncidentEnd::Target => b == owner, + } + }) + .map(|r| r.id) + }; + for key in keys(incident(oa), incident(sa), |id| references.get(id).copied()) { + selectors.insert(FacetSelector::Incident { keys: key, end }); + } + } + for key in keys( + project + .artifact_links + .values() + .filter(|a| a.owner == oa) + .map(|a| a.id), + project + .artifact_links + .values() + .filter(|a| a.owner == sa) + .map(|a| a.id), + |id| references.get(id).copied(), + ) { + for facet in [ + ArtifactFacet::Presence, + ArtifactFacet::Name, + ArtifactFacet::Description, + ArtifactFacet::Owner, + ArtifactFacet::Relation, + ArtifactFacet::Uri, + ArtifactFacet::Revision, + ArtifactFacet::Fragment, + ] { + selectors.insert(FacetSelector::Artifact { + keys: key.clone(), + facet, + }); + } + } + } + let baseline = trace.baseline.as_ref().and_then(|b| b.semantic.as_ref()); + if let Some(base) = baseline { + // Losing a member/branch does not erase its established correspondence. + // Retain the historical pair instead of manufacturing a second one-sided + // fact for the surviving key. A newly paired key is deliberately not + // suppressed by old one-sided history: that mapping still needs reset. + let paired_sides: BTreeSet<_> = base + .facets + .iter() + .map(|fact| super::historical::selector_slots(&fact.selector())) + .filter(|slots| slots.len() == 2) + .flatten() + .collect(); + selectors.retain(|selector| !paired_sides.contains(selector)); + selectors.extend( + base.facets + .iter() + .map(FacetReview::selector) + .filter(|f| include_linked || !linked_selector(f)), + ); + } + selectors + .into_iter() + .map(|selector| { + let current = capture_observation(project, oa, sa, &selector)?; + let old = baseline.and_then(|b| b.facets.iter().find(|v| v.selector() == selector)); + let drift = facet_drift(¤t, old); + Ok(FacetDrift { + selector, + current, + drift, + }) + }) + .collect() +} + +pub(crate) fn write_scalar( + project: &mut Project, + id: Id, + selector: &FacetSelector, + value: Value, +) -> Result<(), String> { + let mut own = Own::get(project, id).ok_or("Missing target record")?; + let path = path(&own, selector) + .ok_or("Facet is inapplicable on target; review its constructor first")?; + if is_constructor(selector) { + return Err("Constructor requires an explicit grouped proposal".into()); + } + *at_mut(&mut own.whole, &path).ok_or("Missing typed target facet")? = value; + if let std::collections::btree_map::Entry::Occupied(mut entry) = project.elements.entry(id) { + entry.insert(serde_json::from_value(own.whole).map_err(|e| e.to_string())?); + } else { + project.relations.insert( + id, + serde_json::from_value(own.whole).map_err(|e| e.to_string())?, + ); + } + Ok(()) +} + +/// Own drift, linked impact, and incomplete review remain distinguishable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SemanticDrift { + pub own: Drift, + pub linked: Drift, + pub own_unreviewed: bool, + pub linked_unreviewed: bool, +} +pub fn trace_semantic_drift( + project: &Project, + trace: &Trace, + references: &BTreeMap, +) -> Result { + let facts = semantic_facets(project, trace, references)?; + let mut own = (false, false, false, false); + let mut linked = (false, false, false, false); + for fact in facts { + let flags = if matches!( + fact.selector, + FacetSelector::Child(_) + | FacetSelector::Incident { .. } + | FacetSelector::Artifact { .. } + ) { + &mut linked + } else { + &mut own + }; + flags.2 = true; + match fact.drift { + Drift::OaChanged => flags.0 = true, + Drift::SaChanged => flags.1 = true, + Drift::BothChanged => { + flags.0 = true; + flags.1 = true; + } + Drift::Unreviewed => flags.3 = true, + _ => {} + } + } + for impact in super::linked_semantic_impacts(project, trace, references)? { + linked.2 = true; + linked.3 |= impact.unreviewed; + match impact.drift { + Drift::OaChanged => linked.0 = true, + Drift::SaChanged => linked.1 = true, + Drift::BothChanged => { + linked.0 = true; + linked.1 = true; + } + Drift::Unreviewed => linked.3 = true, + _ => {} + } + } + let drift = |(a, b, exists, unreviewed): (bool, bool, bool, bool)| match (a, b) { + (true, true) => Drift::BothChanged, + (true, false) => Drift::OaChanged, + (false, true) => Drift::SaChanged, + (false, false) if !exists || unreviewed => Drift::Unreviewed, + _ => Drift::Unchanged, + }; + Ok(SemanticDrift { + own: drift(own), + linked: drift(linked), + own_unreviewed: own.3 || !own.2, + linked_unreviewed: linked.3 || !linked.2, + }) +} + +#[cfg(test)] +mod coverage { + use super::*; + + fn leaves(value: &Value, path: Vec, out: &mut Vec>) { + if let Value::Object(fields) = value { + for (key, value) in fields { + let mut path = path.clone(); + path.push(key.clone()); + leaves(value, path, out); + } + } else { + out.push(path); + } + } + #[test] + fn every_current_payload_field_has_a_closed_typed_capture_and_write_scope() { + let id = Id::nil(); + let mut bodies = Kind::ALL + .into_iter() + .flat_map(|kind| { + [Layer::Oa, Layer::Sa] + .into_iter() + .filter_map(move |layer| ElementBody::new(kind, layer)) + }) + .collect::>(); + let mut add = |body| bodies.push(ElementBody::shared_in(Layer::Sa, body)); + for definition in [ + DataTypeDefinition::Boolean, + DataTypeDefinition::Integer { + minimum: Some(0), + maximum: Some(10), + }, + DataTypeDefinition::Real { + minimum: Some(0.), + maximum: Some(10.), + }, + DataTypeDefinition::String { + minimum_length: 1, + maximum_length: Some(10), + }, + DataTypeDefinition::Enumeration { + literals: vec!["x".into()], + }, + DataTypeDefinition::Record { + supertype: Some(id), + }, + ] { + add(SharedBody::DataType { + definition, + default: Some(DataValue::Integer(1)), + unit: "m".into(), + }); + } + for contract in [ + PortContract::FunctionInput { + incoming_items: vec![id], + }, + PortContract::FunctionOutput { + outgoing_items: vec![id], + }, + PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: ComponentPortOrientation::InOut, + }, + PortContract::Legacy, + ] { + let legacy = matches!(contract, PortContract::Legacy).then_some(LegacyPortContract { + direction: PortDirection::InOut, + interface: Some(id), + }); + add(SharedBody::Port { contract, legacy }); + } + for kind in [ + MessageKind::Asynchronous, + MessageKind::Call, + MessageKind::Reply, + MessageKind::Create, + MessageKind::Delete, + MessageKind::Timer { + expression: "1s".into(), + }, + ] { + add(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: Some(id), + sender: Some(id), + receiver: Some(id), + message_kind: kind, + reply_to: Some(id), + values: vec![MessageValue { + path: vec![id], + field: id, + expression: "x".into(), + }], + }, + precondition: String::new(), + postcondition: String::new(), + }); + } + for body in [ + OccurrenceBody::Action { + action: Some(id), + lifeline: Some(id), + }, + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + OccurrenceBody::SequenceReference { sequence: Some(id) }, + OccurrenceBody::ScenarioUse { + scenario: Some(id), + bindings: vec![LifelineBinding { + referenced: id, + local: id, + }], + }, + OccurrenceBody::State { + state: Some(id), + lifeline: Some(id), + }, + OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![id], + }, + OccurrenceBody::Branch { + guard: "x".into(), + occurrences: vec![id], + }, + ] { + add(SharedBody::Occurrence { + body, + precondition: String::new(), + postcondition: String::new(), + }); + } + for body in [ + EventBody::Signal { items: vec![id] }, + EventBody::Time { + kind: TimeEventKind::After, + expression: "1s".into(), + }, + EventBody::Change { + expression: "changed".into(), + }, + ] { + add(SharedBody::Event { body }); + } + let mut projects = vec![]; + for body in bodies { + let mut p = Project::empty(); + p.elements.insert( + id, + Element { + id, + alias: "record".into(), + name: "name".into(), + description: "description".into(), + parent: None, + body, + }, + ); + projects.push(p); + } + for kind in RelationKind::ALL { + let mut p = Project::empty(); + p.relations.insert( + id, + Relation { + id, + alias: "relation".into(), + name: "name".into(), + description: "description".into(), + body: RelationBody::new(kind, id, id), + }, + ); + projects.push(p); + } + for project in projects { + let own = Own::get(&project, id).unwrap(); + let Some(payload) = at(&own.whole, &own.prefix) else { + continue; + }; + let mut covered = vec![]; + for selector in scalar_selectors() { + if let Some(location) = path(&own, &selector) { + assert!( + capture_facet(&project, id, id, &selector).is_ok(), + "{selector:?}" + ); + if !is_constructor(&selector) + || at(&own.whole, &location).is_some_and(Value::is_string) + { + covered.push(location); + } + } + } + let collection_path = |parts: &[&str]| { + let mut p = own.prefix.clone(); + p.extend(parts.iter().map(|s| s.to_string())); + p + }; + for (kind, parts, which) in [ + ("Process", vec!["steps"], ReferenceCollection::Steps), + ("FunctionalChain", vec!["steps"], ReferenceCollection::Steps), + ("Scenario", vec!["steps"], ReferenceCollection::Steps), + ( + "Occurrence", + vec!["body", "Branch", "occurrences"], + ReferenceCollection::Steps, + ), + ( + "Occurrence", + vec!["body", "Fragment", "branches"], + ReferenceCollection::FragmentBranches, + ), + ( + "Port", + vec!["contract", "FunctionInput", "incoming_items"], + ReferenceCollection::FunctionInputItems, + ), + ( + "Port", + vec!["contract", "FunctionOutput", "outgoing_items"], + ReferenceCollection::FunctionOutputItems, + ), + ( + "Event", + vec!["body", "Signal", "items"], + ReferenceCollection::SignalItems, + ), + ( + "Interaction", + vec!["items"], + ReferenceCollection::ExchangeItems, + ), + ( + "FunctionalExchange", + vec!["items"], + ReferenceCollection::ExchangeItems, + ), + ( + "BoundaryExchange", + vec!["items"], + ReferenceCollection::ExchangeItems, + ), + ( + "StateTransition", + vec!["triggers"], + ReferenceCollection::TransitionTriggers, + ), + ( + "StateTransition", + vec!["effects"], + ReferenceCollection::TransitionEffects, + ), + ( + "Constraint", + vec!["constrained"], + ReferenceCollection::Constrained, + ), + ] { + let location = collection_path(&parts); + if own.kind == kind && at(&own.whole, &location).is_some() { + assert!( + collection(&project, id, which).is_some(), + "{kind} {parts:?}" + ); + covered.push(location); + } + } + for (kind, parts) in [ + ("Occurrence", vec!["body", "Exchange", "values"]), + ("Occurrence", vec!["body", "ScenarioUse", "bindings"]), + ("DataType", vec!["definition", "Enumeration", "literals"]), + ] { + if own.kind == kind { + covered.push(collection_path(&parts)); + } + } + // A typed port has no legacy payload; absence is a constructor invariant. + if own.kind == "Port" && payload.get("legacy") == Some(&Value::Null) { + covered.push(collection_path(&["legacy"])); + } + let mut fields = vec![]; + leaves(payload, own.prefix.clone(), &mut fields); + for field in fields { + assert!( + covered.iter().any(|path| field.starts_with(path)), + "Uncovered {} payload field {field:?}", + own.kind + ); + } + } + } +} diff --git a/crates/oa-core/src/semantic/historical.rs b/crates/oa-core/src/semantic/historical.rs new file mode 100644 index 00000000..8f5e1383 --- /dev/null +++ b/crates/oa-core/src/semantic/historical.rs @@ -0,0 +1,404 @@ +use super::types::*; +use crate::model::*; +use std::collections::BTreeSet; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HistoricalSourceKind { + Reference, + Enum, +} + +const MAX_FACTS: usize = 16_384; +const MAX_LEAVES: usize = 1_000_000; +const MAX_PATH: usize = 128; + +struct Walk<'a> { + path: Vec, + slots: Vec<(Vec, HistoricalSourceKind)>, + errors: Vec, + leaves: usize, + visitor: &'a mut dyn FnMut(&mut Id), +} +impl Walk<'_> { + fn at(&mut self, key: impl ToString, f: impl FnOnce(&mut Self)) { + if self.leaves >= MAX_LEAVES { + return; + } + self.leaves += 1; + self.path.push(key.to_string()); + f(self); + self.path.pop(); + } + fn slot(&mut self, kind: HistoricalSourceKind) { + self.slots.push((self.path.clone(), kind)); + } + fn error(&mut self, message: &str) { + self.errors + .push(format!("{}: {message}", self.path.join("."))); + } +} +trait Historical { + fn walk(&mut self, w: &mut Walk<'_>); +} +impl Historical for Id { + fn walk(&mut self, w: &mut Walk<'_>) { + w.slot(HistoricalSourceKind::Reference); + (w.visitor)(self); + } +} +macro_rules! plain { + ($($ty:ty),*) => { $( impl Historical for $ty { fn walk(&mut self, _: &mut Walk<'_>) {} } )* }; +} +plain!(bool, u32, i64); +impl Historical for String { + fn walk(&mut self, w: &mut Walk<'_>) { + if !crate::validation::valid_text(self) { + w.error("historical text contains an invalid character"); + } + if self.len() > 1_000_000 { + w.error("historical text work limit exceeded"); + } + } +} +impl Historical for f64 { + fn walk(&mut self, w: &mut Walk<'_>) { + if !self.is_finite() { + w.error("historical real must be finite"); + } + } +} +macro_rules! enums { + ($($ty:ty),*) => { $( impl Historical for $ty { + fn walk(&mut self, w: &mut Walk<'_>) { w.slot(HistoricalSourceKind::Enum); } + } )* }; +} +enums!( + ExchangeItemKind, + ExchangeMechanism, + ParameterDirection, + FieldStorage, + ComponentPortKind, + ComponentPortOrientation, + PortDirection, + ScenarioViewpoint, + ControlKind, + FragmentOperator, + TimeEventKind, + StateKind, + TransmissionProtocol, + AcquisitionProtocol, + TransitionKind, + StateActionKind, + ArtifactRelation, + ReferenceCollection, + OrderedCollection, + IncidentEnd, + DataConstructor, + PortConstructor, + OccurrenceConstructor, + EventConstructor, + MessageConstructor +); +impl Historical for Option { + fn walk(&mut self, w: &mut Walk<'_>) { + if let Some(v) = self { + v.walk(w); + } + } +} +impl Historical for Vec { + fn walk(&mut self, w: &mut Walk<'_>) { + for (i, v) in self.iter_mut().enumerate().take(MAX_LEAVES) { + w.at(i, |w| v.walk(w)); + } + if self.len() > MAX_LEAVES { + w.error("historical collection work limit exceeded"); + } + } +} +impl Historical for Observed { + fn walk(&mut self, w: &mut Walk<'_>) { + match self { + Self::NotApplicable => w.slot(HistoricalSourceKind::Enum), + Self::Value(v) => w.at("Value", |w| v.walk(w)), + } + } +} +impl Historical for Reviewed { + fn walk(&mut self, w: &mut Walk<'_>) { + if matches!( + (&self.oa, &self.sa), + (Observed::NotApplicable, Observed::NotApplicable) + ) { + w.error("a reviewed facet must apply on at least one side"); + } + w.at("oa", |w| self.oa.walk(w)); + w.at("sa", |w| self.sa.walk(w)); + } +} +impl Historical for KeyPair { + fn walk(&mut self, w: &mut Walk<'_>) { + if self.oa.is_none() && self.sa.is_none() { + w.error("key pair cannot be empty on both sides"); + } + w.at("oa", |w| self.oa.walk(w)); + w.at("sa", |w| self.sa.walk(w)); + } +} +impl Historical for AnnotationKey { + fn walk(&mut self, w: &mut Walk<'_>) { + if self.path.len() > MAX_PATH { + w.error("historical annotation path limit exceeded"); + } + w.at("path", |w| self.path.walk(w)); + w.at("field", |w| self.field.walk(w)); + } +} +impl Historical for MessageValue { + fn walk(&mut self, w: &mut Walk<'_>) { + if self.path.len() > MAX_PATH { + w.error("historical annotation path limit exceeded"); + } + w.at("path", |w| self.path.walk(w)); + w.at("field", |w| self.field.walk(w)); + w.at("expression", |w| self.expression.walk(w)); + } +} +impl Historical for LifelineBinding { + fn walk(&mut self, w: &mut Walk<'_>) { + w.at("referenced", |w| self.referenced.walk(w)); + w.at("local", |w| self.local.walk(w)); + } +} +impl Historical for DataValue { + fn walk(&mut self, w: &mut Walk<'_>) { + match self { + Self::Real(value) => w.at("Real", |w| value.walk(w)), + Self::String(value) => w.at("String", |w| value.walk(w)), + Self::Enumeration(value) => w.at("Enumeration", |w| value.walk(w)), + Self::Boolean(_) | Self::Integer(_) => {} + } + } +} +impl Historical for TypeRef { + fn walk(&mut self, w: &mut Walk<'_>) { + match self { + Self::Named(id) => w.at("Named", |w| id.walk(w)), + _ => w.slot(HistoricalSourceKind::Enum), + } + } +} +impl Historical for ArtifactReview { + fn walk(&mut self, w: &mut Walk<'_>) { + match self { + Self::Presence(v) => w.at("Presence", |w| v.walk(w)), + Self::Name(v) => w.at("Name", |w| v.walk(w)), + Self::Description(v) => w.at("Description", |w| v.walk(w)), + Self::Owner(v) => w.at("Owner", |w| v.walk(w)), + Self::Relation(v) => w.at("Relation", |w| v.walk(w)), + Self::Uri(v) => w.at("Uri", |w| v.walk(w)), + Self::Revision(v) => w.at("Revision", |w| v.walk(w)), + Self::Fragment(v) => w.at("Fragment", |w| v.walk(w)), + } + } +} +macro_rules! family_visitor { + ($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => { + impl Historical for FacetReview { + fn walk(&mut self, w: &mut Walk<'_>) { + match self { + $( Self::$family(v) => w.at(stringify!($family), |w| match v { + $( $review::$leaf(v) => w.at(stringify!($leaf), |w| v.walk(w)), )* + }), )* + Self::Member { collection, keys, review } => w.at("Member", |w| { + w.at("collection", |w| collection.walk(w)); w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w)); + }), + Self::Order { collection, review } => w.at("Order", |w| { + w.at("collection", |w| collection.walk(w)); w.at("review", |w| review.walk(w)); + for side in [&review.oa, &review.sa] { if let Observed::Value(order) = side { + if order.iter().collect::>().len() != order.len() { w.error("duplicate historical order member"); } + }} + }), + Self::Literal { keys, review } => w.at("Literal", |w| { + w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w)); + }), + Self::Annotation { keys, review } => w.at("Annotation", |w| { + w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w)); + for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] { + if let Observed::Value(Some(row)) = value { + if key.as_ref() != Some(&AnnotationKey::from(row)) { w.error("historical annotation key and payload disagree"); } + } + } + }), + Self::Binding { keys, review } => w.at("Binding", |w| { + w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w)); + for (key, value) in [(&keys.oa, &review.oa), (&keys.sa, &review.sa)] { + if let Observed::Value(Some(row)) = value { + if *key != Some(row.referenced) { w.error("historical binding key and payload disagree"); } + } + } + }), + Self::Child { keys, review } => w.at("Child", |w| { + w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w)); + }), + Self::Incident { keys, end, review } => w.at("Incident", |w| { + w.at("keys", |w| keys.walk(w)); w.at("end", |w| end.walk(w)); w.at("review", |w| review.walk(w)); + }), + Self::Artifact { keys, review } => w.at("Artifact", |w| { + w.at("keys", |w| keys.walk(w)); w.at("review", |w| review.walk(w)); + }), + } + } + } + }; +} +scalar_families!(family_visitor); + +fn walk_baseline<'a>( + baseline: &mut SemanticBaseline, + visitor: &'a mut dyn FnMut(&mut Id), +) -> Walk<'a> { + let mut walk = Walk { + path: vec!["facets".into()], + slots: vec![], + errors: vec![], + leaves: 0, + visitor, + }; + if baseline.facets.len() > MAX_FACTS { + walk.error("historical facet count limit exceeded"); + } + for (i, facet) in baseline.facets.iter_mut().enumerate().take(MAX_FACTS) { + walk.at(i, |w| facet.walk(w)); + } + if walk.leaves >= MAX_LEAVES { + walk.error("historical reference work limit exceeded"); + } + walk +} +/// Historical UUIDs are visited only on explicit identity remapping, never deletion. +pub fn visit_semantic_references( + baseline: &mut SemanticBaseline, + mut visitor: impl FnMut(&mut Id), +) { + walk_baseline(baseline, &mut visitor); +} +pub(crate) fn historical_source_slots( + baseline: &SemanticBaseline, +) -> Vec<(Vec, HistoricalSourceKind)> { + walk_baseline(&mut baseline.clone(), &mut |_| {}).slots +} +/// Checks persisted facts without demanding that historical references still exist. +pub fn validate_semantic_baseline(baseline: &SemanticBaseline) -> Result<(), Vec> { + let mut errors = walk_baseline(&mut baseline.clone(), &mut |_| {}).errors; + let mut slots = BTreeSet::new(); + for selector in baseline + .facets + .iter() + .take(MAX_FACTS) + .map(FacetReview::selector) + { + if selector_slots(&selector) + .into_iter() + .any(|slot| !slots.insert(slot)) + { + errors.push(format!( + "duplicate or overlapping historical facet: {selector:?}" + )); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } +} +pub(super) fn selector_slots(selector: &FacetSelector) -> Vec { + fn split(keys: &KeyPair) -> Vec> { + let mut result = vec![]; + if let Some(oa) = &keys.oa { + result.push(KeyPair { + oa: Some(oa.clone()), + sa: None, + }); + } + if let Some(sa) = &keys.sa { + result.push(KeyPair { + oa: None, + sa: Some(sa.clone()), + }); + } + result + } + match selector { + FacetSelector::Member { collection, keys } => split(keys) + .into_iter() + .map(|keys| FacetSelector::Member { + collection: *collection, + keys, + }) + .collect(), + FacetSelector::Literal(keys) => split(keys) + .into_iter() + .map(FacetSelector::Literal) + .collect(), + FacetSelector::Annotation(keys) => split(keys) + .into_iter() + .map(FacetSelector::Annotation) + .collect(), + FacetSelector::Binding(keys) => split(keys) + .into_iter() + .map(FacetSelector::Binding) + .collect(), + FacetSelector::Child(keys) => split(keys).into_iter().map(FacetSelector::Child).collect(), + FacetSelector::Incident { keys, end } => split(keys) + .into_iter() + .map(|keys| FacetSelector::Incident { keys, end: *end }) + .collect(), + FacetSelector::Artifact { keys, facet } => split(keys) + .into_iter() + .map(|keys| FacetSelector::Artifact { + keys, + facet: *facet, + }) + .collect(), + value => vec![value.clone()], + } +} +fn pair_overlap(a: &KeyPair, b: &KeyPair) -> bool { + a.oa.as_ref() + .zip(b.oa.as_ref()) + .is_some_and(|(a, b)| a == b) + || a.sa + .as_ref() + .zip(b.sa.as_ref()) + .is_some_and(|(a, b)| a == b) +} +/// Slot identity is each actual side's key, not equality of the complete pair. +pub(crate) fn keyed_slots_overlap(a: &FacetSelector, b: &FacetSelector) -> bool { + match (a, b) { + ( + FacetSelector::Member { + collection: a, + keys: ak, + }, + FacetSelector::Member { + collection: b, + keys: bk, + }, + ) => a == b && pair_overlap(ak, bk), + (FacetSelector::Literal(a), FacetSelector::Literal(b)) => pair_overlap(a, b), + (FacetSelector::Annotation(a), FacetSelector::Annotation(b)) => pair_overlap(a, b), + (FacetSelector::Binding(a), FacetSelector::Binding(b)) => pair_overlap(a, b), + (FacetSelector::Child(a), FacetSelector::Child(b)) => pair_overlap(a, b), + ( + FacetSelector::Incident { keys: ak, end: a }, + FacetSelector::Incident { keys: bk, end: b }, + ) => a == b && pair_overlap(ak, bk), + ( + FacetSelector::Artifact { keys: ak, facet: a }, + FacetSelector::Artifact { keys: bk, facet: b }, + ) => a == b && pair_overlap(ak, bk), + _ => a == b, + } +} diff --git a/crates/oa-core/src/semantic/linked.rs b/crates/oa-core/src/semantic/linked.rs new file mode 100644 index 00000000..41741e03 --- /dev/null +++ b/crates/oa-core/src/semantic/linked.rs @@ -0,0 +1,174 @@ +use super::*; +use crate::model::*; +use crate::transitions::Drift; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LinkedSemanticImpact { + pub objects: KeyPair, + pub traces: Vec, + pub drift: Drift, + pub unreviewed: bool, +} +const MAX_LINKED_OBJECTS: usize = 4_096; +const MAX_LINKED_EDGES: usize = 32_768; + +/// Derived dependency diagnostics, never another historical baseline. Follow +/// direct references/children, then only reusable dictionary/event definitions. +/// Association endpoints and ordinary architecture neighborhoods do not expand. +pub fn linked_semantic_impacts( + project: &Project, + trace: &Trace, + references: &BTreeMap, +) -> Result, String> { + let mut children: BTreeMap> = BTreeMap::new(); + for element in project.elements.values() { + if let Some(parent) = element.parent { + children.entry(parent).or_default().push(element.id); + } + } + let mut contracts: BTreeMap> = BTreeMap::new(); + for relation in project.relations.values() { + if let RelationBody::InterfaceItemAllocation { source, .. } = relation.body { + contracts.entry(source).or_default().push(relation.id); + } + } + let direct = |id| -> Vec { + let mut out = vec![]; + if let Some(element) = project.elements.get(&id) { + out.extend( + crate::references::element_references(element) + .into_iter() + .filter(|r| Some(*r) != element.parent), + ); + } + if let Some(relation) = project.relations.get(&id) { + out.extend(crate::references::relation_references(relation)); + } + out.extend(children.get(&id).into_iter().flatten().copied()); + out.extend(contracts.get(&id).into_iter().flatten().copied()); + out + }; + let expandable = |id| { + project.elements.get(&id).is_some_and(|e| { + matches!( + e.kind(), + Kind::DataType + | Kind::DataField + | Kind::ExchangeItem + | Kind::Interface + | Kind::Event + ) + }) || project + .relations + .get(&id) + .is_some_and(|r| matches!(r.body, RelationBody::InterfaceItemAllocation { .. })) + }; + let mut edges = 0usize; + let mut traverse = |root| -> Result, String> { + let mut seen = BTreeSet::from([root]); + let first = direct(root); + edges += first.len(); + if edges > MAX_LINKED_EDGES { + return Err( + "Linked semantic dependency work limit exceeded; narrow the review scope".into(), + ); + } + let mut queue: VecDeque<_> = first.into(); + while let Some(id) = queue.pop_front() { + if !seen.insert(id) { + continue; + } + if seen.len() > MAX_LINKED_OBJECTS || edges > MAX_LINKED_EDGES { + return Err( + "Linked semantic dependency work limit exceeded; narrow the review scope" + .into(), + ); + } + if expandable(id) { + let next = direct(id); + edges += next.len(); + if edges > MAX_LINKED_EDGES { + return Err( + "Linked semantic dependency work limit exceeded; narrow the review scope" + .into(), + ); + } + queue.extend(next); + } + } + seen.remove(&root); + Ok(seen) + }; + let oa = traverse(trace.oa)?; + let sa = traverse(trace.sa)?; + let mut traced: BTreeMap<(Id, Id), Vec<&Trace>> = BTreeMap::new(); + for t in project.traces.values() { + traced.entry((t.oa, t.sa)).or_default().push(t); + } + let mut pairs = vec![]; + let mut used = BTreeSet::new(); + for id in oa { + let target = references.get(&id).copied(); + if let Some(id) = target { + used.insert(id); + } + pairs.push(KeyPair { + oa: Some(id), + sa: target, + }); + } + for id in sa.difference(&used) { + pairs.push(KeyPair { + oa: None, + sa: Some(*id), + }); + } + let mut result = vec![]; + for objects in pairs { + if objects.oa == Some(trace.oa) && objects.sa == Some(trace.sa) { + continue; + } + let links = objects + .oa + .zip(objects.sa) + .and_then(|pair| traced.get(&pair)); + let mut flags = (false, false, links.is_none()); + let mut ids = vec![]; + for linked in links.into_iter().flatten() { + ids.push(linked.id); + let facts = super::catalog::own_semantic_facets(project, linked, references)?; + if facts.is_empty() { + flags.2 = true; + } + for fact in facts { + match fact.drift { + Drift::OaChanged => flags.0 = true, + Drift::SaChanged => flags.1 = true, + Drift::BothChanged => { + flags.0 = true; + flags.1 = true; + } + Drift::Unreviewed => flags.2 = true, + _ => {} + } + } + } + let drift = match flags { + (true, true, _) => Drift::BothChanged, + (true, false, _) => Drift::OaChanged, + (false, true, _) => Drift::SaChanged, + (false, false, true) => Drift::Unreviewed, + _ => Drift::Unchanged, + }; + result.push(LinkedSemanticImpact { + objects, + traces: ids, + drift, + unreviewed: flags.2, + }); + } + Ok(result) +} diff --git a/crates/oa-core/src/semantic/mutate.rs b/crates/oa-core/src/semantic/mutate.rs new file mode 100644 index 00000000..bc31f624 --- /dev/null +++ b/crates/oa-core/src/semantic/mutate.rs @@ -0,0 +1,592 @@ +use super::catalog::{collection, order_collection, sides, write_scalar}; +use super::*; +use crate::model::*; +use crate::transitions::TransitionDirection; +use std::collections::{BTreeMap, BTreeSet}; + +pub(crate) fn set_collection( + p: &mut Project, + id: Id, + which: ReferenceCollection, + list: Vec, +) -> Result<(), String> { + if list.iter().collect::>().len() != list.len() { + return Err("Collection contains duplicate members".into()); + } + use ReferenceCollection as C; + if which == C::Steps { + let e = p.elements.get_mut(&id).ok_or("Missing sequence")?; + if let Some(steps) = e.steps_mut() { + *steps = list; + return Ok(()); + } + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Branch { occurrences, .. }, + .. + }) = e.shared_mut() + { + *occurrences = list; + return Ok(()); + } + return Err("Target has no ordered steps".into()); + } + let slot = match which { + C::Steps => unreachable!(), + C::FragmentBranches => match p.elements.get_mut(&id).and_then(Element::shared_mut) { + Some(SharedBody::Occurrence { + body: OccurrenceBody::Fragment { branches, .. }, + .. + }) => Some(branches), + _ => None, + }, + C::FunctionInputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) { + Some(SharedBody::Port { + contract: PortContract::FunctionInput { incoming_items }, + .. + }) => Some(incoming_items), + _ => None, + }, + C::FunctionOutputItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) { + Some(SharedBody::Port { + contract: PortContract::FunctionOutput { outgoing_items }, + .. + }) => Some(outgoing_items), + _ => None, + }, + C::SignalItems => match p.elements.get_mut(&id).and_then(Element::shared_mut) { + Some(SharedBody::Event { + body: EventBody::Signal { items }, + }) => Some(items), + _ => None, + }, + C::ExchangeItems => match p.relations.get_mut(&id).map(|r| &mut r.body) { + Some( + RelationBody::Interaction { items, .. } + | RelationBody::FunctionalExchange { items, .. } + | RelationBody::BoundaryExchange { items, .. }, + ) => Some(items), + _ => None, + }, + C::TransitionTriggers => match p.relations.get_mut(&id).map(|r| &mut r.body) { + Some(RelationBody::StateTransition { triggers, .. }) => Some(triggers), + _ => None, + }, + C::TransitionEffects => match p.relations.get_mut(&id).map(|r| &mut r.body) { + Some(RelationBody::StateTransition { effects, .. }) => Some(effects), + _ => None, + }, + C::Constrained => match p.elements.get_mut(&id).and_then(Element::shared_mut) { + Some(SharedBody::Constraint { constrained, .. }) => Some(constrained), + _ => None, + }, + } + .ok_or("Collection is inapplicable on target")?; + *slot = list; + Ok(()) +} +pub(crate) fn chosen(pair: &KeyPair, layer: Layer) -> Option<&T> { + match layer { + Layer::Oa => pair.oa.as_ref(), + Layer::Sa => pair.sa.as_ref(), + } +} +pub(crate) fn value(review: &Reviewed, layer: Layer) -> Result<&T, String> { + match match layer { + Layer::Oa => &review.oa, + Layer::Sa => &review.sa, + } { + Observed::Value(v) => Ok(v), + Observed::NotApplicable => { + Err("Facet does not exist in the selected constructor branch".into()) + } + } +} +pub(crate) fn directional_map( + map: &BTreeMap, + direction: TransitionDirection, +) -> Result, String> { + if direction == TransitionDirection::OaToSa { + return Ok(map.clone()); + } + let mut inverse = BTreeMap::new(); + for (oa, sa) in map { + if inverse.insert(*sa, *oa).is_some_and(|old| old != *oa) { + return Err(format!( + "Reverse reference {sa} has multiple OA correspondences; supply an unambiguous mapping" + )); + } + } + Ok(inverse) +} +pub(crate) fn remap_record( + record: &mut SemanticRecord, + map: &BTreeMap, +) -> Result<(), String> { + let mut missing = BTreeSet::new(); + let mut remap = |id: &mut Id| { + if let Some(new) = map.get(id) { + *id = *new; + } else { + missing.insert(*id); + } + }; + match record { + SemanticRecord::Element(e) => crate::references::visit_element_refs(e, &mut remap), + SemanticRecord::Relation(r) => crate::references::visit_relation_refs(r, &mut remap), + SemanticRecord::Artifact(a) => remap(&mut a.owner), + } + if missing.is_empty() { + Ok(()) + } else { + Err(format!( + "Missing explicit reference correspondence: {missing:?}" + )) + } +} +pub(crate) fn remap_fact( + fact: &FacetReview, + map: &BTreeMap, + source: Layer, +) -> Result { + // Remap only the selected observation. Keys remain in canonical OA/SA space. + let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?; + let base = SemanticBaseline { + facets: vec![fact.clone()], + }; + let side = match source { + Layer::Oa => "oa", + Layer::Sa => "sa", + }; + let mut missing = BTreeSet::new(); + for (path, kind) in historical_source_slots(&base) { + if kind != HistoricalSourceKind::Reference { + continue; + } + let rest = &path[2..]; + if !rest.windows(2).any(|w| w[0] == side && w[1] == "Value") { + continue; + } + let mut leaf = &mut json; + for part in rest { + leaf = if leaf.is_array() { + leaf.get_mut( + part.parse::() + .map_err(|_| "Invalid historical array index")?, + ) + } else { + leaf.get_mut(part) + } + .ok_or("Invalid historical source slot")?; + } + let id: Id = serde_json::from_value(leaf.clone()).map_err(|e| e.to_string())?; + if let Some(new) = map.get(&id) { + *leaf = serde_json::to_value(new).unwrap(); + } else { + missing.insert(id); + } + } + if !missing.is_empty() { + return Err(format!( + "Missing explicit reference correspondence: {missing:?}" + )); + } + serde_json::from_value(json).map_err(|e| e.to_string()) +} +pub(crate) fn check_keys(selector: &FacetSelector, map: &BTreeMap) -> Result<(), String> { + fn pair(keys: &KeyPair, map: &BTreeMap) -> Result<(), String> { + if let (Some(oa), Some(sa)) = (keys.oa, keys.sa) + && map.get(&oa) != Some(&sa) + { + return Err("Key pair disagrees with frozen reference correspondence".into()); + } + if keys.oa.is_none() && keys.sa.is_none() { + return Err("Empty key pair".into()); + } + Ok(()) + } + match selector { + FacetSelector::Member { keys, .. } + | FacetSelector::Binding(keys) + | FacetSelector::Child(keys) + | FacetSelector::Incident { keys, .. } + | FacetSelector::Artifact { keys, .. } => pair(keys, map), + FacetSelector::Annotation(keys) => { + if let (Some(oa), Some(sa)) = (&keys.oa, &keys.sa) { + if oa.path.len() != sa.path.len() { + return Err("Annotation path pairing disagrees".into()); + } + for (oa, sa) in oa + .path + .iter() + .chain([&oa.field]) + .zip(sa.path.iter().chain([&sa.field])) + { + pair( + &KeyPair { + oa: Some(*oa), + sa: Some(*sa), + }, + map, + )?; + } + } + if keys.oa.is_none() && keys.sa.is_none() { + return Err("Empty annotation pair".into()); + } + Ok(()) + } + FacetSelector::Literal(keys) => { + if keys + .oa + .as_ref() + .zip(keys.sa.as_ref()) + .is_some_and(|(oa, sa)| oa != sa) + { + return Err( + "Enum literal identity is exact text; renaming is removal/addition".into(), + ); + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Apply just one selected fact to an in-memory expected project. The caller +/// subsequently prepares ordinary Commands and checks every induced difference. +pub(crate) fn write_fact( + p: &mut Project, + target: Id, + fact: &FacetReview, + from: Layer, + target_layer: Layer, + preserve_target_only: bool, +) -> Result<(), String> { + match fact { + FacetReview::Member { + collection: which, + keys, + review, + } => { + if preserve_target_only && chosen(keys, from).is_none() { + return Ok(()); + } + let present = *value(review, from)?; + let key = chosen(keys, target_layer) + .copied() + .ok_or("Map the target collection member explicitly")?; + let mut list = collection(p, target, *which).ok_or("Inapplicable target collection")?; + if present { + if !list.contains(&key) { + list.push(key); + } + } else { + list.retain(|id| *id != key); + } + set_collection(p, target, *which, list) + } + FacetReview::Order { + collection: which, + review, + } => { + let supplied = value(review, from)?.clone(); + let current = collection(p, target, order_collection(*which)) + .ok_or("Inapplicable target order")?; + let supplied_set: BTreeSet<_> = supplied.iter().copied().collect(); + let current_set: BTreeSet<_> = current.iter().copied().collect(); + if supplied_set != current_set { + if preserve_target_only + && supplied_set.is_subset(¤t_set) + && current + .iter() + .filter(|id| supplied_set.contains(id)) + .copied() + .collect::>() + == supplied + { + return Ok(()); + } + return Err("Order must contain every surviving target member exactly once; target-only insertion requires an explicit final order".into()); + } + set_collection(p, target, order_collection(*which), supplied) + } + FacetReview::Literal { keys, review } => { + if preserve_target_only && chosen(keys, from).is_none() { + return Ok(()); + } + let present = *value(review, from)?; + let key = chosen(keys, target_layer).ok_or("Missing literal key on target")?; + let Some(SharedBody::DataType { + definition: DataTypeDefinition::Enumeration { literals }, + .. + }) = p.elements.get_mut(&target).and_then(Element::shared_mut) + else { + return Err("Target is not an enumeration".into()); + }; + if present { + if !literals.contains(key) { + literals.push(key.clone()); + } + } else { + literals.retain(|v| v != key); + } + Ok(()) + } + FacetReview::Annotation { keys, review } => { + if preserve_target_only && chosen(keys, from).is_none() { + return Ok(()); + } + let key = chosen(keys, target_layer).ok_or("Missing target annotation key")?; + let row = value(review, from)?.clone(); + if row.as_ref().is_some_and(|v| AnnotationKey::from(v) != *key) { + return Err( + "Annotation candidate changes its row key; review removal/addition".into(), + ); + } + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = p.elements.get_mut(&target).and_then(Element::shared_mut) + else { + return Err("Target has no message annotations".into()); + }; + values.retain(|v| AnnotationKey::from(v) != *key); + if let Some(row) = row { + values.push(row); + } + Ok(()) + } + FacetReview::Binding { keys, review } => { + if preserve_target_only && chosen(keys, from).is_none() { + return Ok(()); + } + let key = *chosen(keys, target_layer).ok_or("Missing target binding key")?; + let row = value(review, from)?.clone(); + if row.as_ref().is_some_and(|v| v.referenced != key) { + return Err( + "Binding candidate changes its row key; review removal/addition".into(), + ); + } + let Some(SharedBody::Occurrence { + body: OccurrenceBody::ScenarioUse { bindings, .. }, + .. + }) = p.elements.get_mut(&target).and_then(Element::shared_mut) + else { + return Err("Target has no scenario bindings".into()); + }; + bindings.retain(|v| v.referenced != key); + if let Some(row) = row { + bindings.push(row); + } + Ok(()) + } + FacetReview::Child { keys, review } => { + if preserve_target_only && chosen(keys, from).is_none() { + return Ok(()); + } + let key = chosen(keys, target_layer).ok_or("Map target child explicitly")?; + let e = p + .elements + .get_mut(key) + .ok_or("Target child does not exist; create it through transition mapping")?; + if *value(review, from)? { + e.parent = Some(target); + } else if e.parent == Some(target) { + e.parent = None; + } + Ok(()) + } + FacetReview::Incident { keys, end, review } => { + if preserve_target_only && chosen(keys, from).is_none() { + return Ok(()); + } + let key = chosen(keys, target_layer).ok_or("Map incident relation explicitly")?; + if !*value(review, from)? { + return Err("Unlinking an incident relation requires an explicit final endpoint through the relation trace".into()); + } + let relation = p + .relations + .get_mut(key) + .ok_or("Mapped incident relation is absent")?; + let (a, b) = relation.body.endpoints(); + match end { + IncidentEnd::Source => relation.body.set_endpoints(target, b), + IncidentEnd::Target => relation.body.set_endpoints(a, target), + } + Ok(()) + } + FacetReview::Artifact { keys, review } => { + if preserve_target_only && chosen(keys, from).is_none() { + return Ok(()); + } + let key = chosen(keys, target_layer).ok_or("Map artifact explicitly")?; + if let ArtifactReview::Presence(review) = review { + if !*value(review, from)? { + p.artifact_links.remove(key); + return Ok(()); + } + if p.artifact_links.get(key).is_some_and(|a| a.owner == target) { + return Ok(()); + } + return Err("Artifact creation requires a complete typed candidate".into()); + } + let a = p + .artifact_links + .get_mut(key) + .ok_or("Artifact candidate is absent")?; + if a.owner != target { + return Err( + "Artifact metadata can only be reviewed on its currently attached owner trace" + .into(), + ); + } + match review { + ArtifactReview::Presence(_) => unreachable!(), + ArtifactReview::Name(r) => a.name = value(r, from)?.clone(), + ArtifactReview::Description(r) => a.description = value(r, from)?.clone(), + ArtifactReview::Owner(r) => a.owner = *value(r, from)?, + ArtifactReview::Relation(r) => a.relation = *value(r, from)?, + ArtifactReview::Uri(r) => a.uri = value(r, from)?.clone(), + ArtifactReview::Revision(r) => a.revision = value(r, from)?.clone(), + ArtifactReview::Fragment(r) => a.fragment = value(r, from)?.clone(), + } + Ok(()) + } + _ => { + let (oa, sa) = sides(fact); + let side = match from { + Layer::Oa => oa, + Layer::Sa => sa, + }; + let value = side + .get("Value") + .ok_or("Selected branch is not applicable")? + .clone(); + write_scalar(p, target, &fact.selector(), value) + } + } +} + +pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector) -> bool { + use FacetSelector as F; + match group { + F::Data(DataFacet::Constructor) => { + matches!(facet,F::Data(f) if *f != DataFacet::Unit) || matches!(facet, F::Literal(_)) + } + F::Port(PortFacet::Constructor) => matches!( + facet, + F::Port(_) + | F::Member { + collection: ReferenceCollection::FunctionInputItems + | ReferenceCollection::FunctionOutputItems, + .. + } + ), + F::Occurrence(OccurrenceFacet::Constructor) => { + matches!(facet,F::Occurrence(f) if !matches!(f,OccurrenceFacet::Precondition | OccurrenceFacet::Postcondition)) + || matches!( + facet, + F::Annotation(_) + | F::Binding(_) + | F::Member { + collection: ReferenceCollection::Steps + | ReferenceCollection::FragmentBranches, + .. + } + | F::Order(_) + ) + } + F::Occurrence(OccurrenceFacet::MessageKind) => matches!( + facet, + F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression) + ), + F::Event(EventFacet::Constructor) => matches!( + facet, + F::Event(_) + | F::Member { + collection: ReferenceCollection::SignalItems, + .. + } + ), + F::State(StateFacet::Kind) => facet == group, + _ => false, + } +} +pub(crate) fn copy_constructor( + p: &mut Project, + target: Id, + source: &SemanticRecord, + group: &FacetSelector, +) -> Result<(), String> { + let SemanticRecord::Element(source) = source else { + return Err("Constructor source must be an element".into()); + }; + let target = p + .elements + .get_mut(&target) + .ok_or("Missing target element")?; + let a = source.shared().ok_or("Source has no shared constructor")?; + let b = target + .shared_mut() + .ok_or("Target has no shared constructor")?; + match (group, a, b) { + ( + FacetSelector::Data(DataFacet::Constructor), + SharedBody::DataType { + definition, + default, + .. + }, + SharedBody::DataType { + definition: d, + default: v, + .. + }, + ) => { + *d = definition.clone(); + *v = default.clone(); + } + ( + FacetSelector::Port(PortFacet::Constructor), + SharedBody::Port { contract, legacy }, + SharedBody::Port { + contract: c, + legacy: l, + }, + ) => { + *c = contract.clone(); + *l = legacy.clone(); + } + ( + FacetSelector::Occurrence(OccurrenceFacet::Constructor), + SharedBody::Occurrence { body, .. }, + SharedBody::Occurrence { body: b, .. }, + ) => *b = body.clone(), + ( + FacetSelector::Occurrence(OccurrenceFacet::MessageKind), + SharedBody::Occurrence { + body: OccurrenceBody::Exchange { message_kind, .. }, + .. + }, + SharedBody::Occurrence { + body: + OccurrenceBody::Exchange { + message_kind: k, .. + }, + .. + }, + ) => *k = message_kind.clone(), + ( + FacetSelector::Event(EventFacet::Constructor), + SharedBody::Event { body }, + SharedBody::Event { body: b }, + ) => *b = body.clone(), + ( + FacetSelector::State(StateFacet::Kind), + SharedBody::State { kind, .. }, + SharedBody::State { kind: k, .. }, + ) => *k = *kind, + _ => return Err("Constructor family mismatch".into()), + } + Ok(()) +} diff --git a/crates/oa-core/src/semantic/review.rs b/crates/oa-core/src/semantic/review.rs new file mode 100644 index 00000000..f1278eaf --- /dev/null +++ b/crates/oa-core/src/semantic/review.rs @@ -0,0 +1,1135 @@ +use super::catalog::{capture_observation, collection, is_constructor, replace_observation, sides}; +use super::historical::keyed_slots_overlap; +use super::mutate::*; +use super::*; +use crate::model::*; +use crate::transitions::{self, TransitionDirection, TransitionPlan, TransitionRequest}; +use crate::{Command, ModelError, PreparedCommand, prepare_command}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SemanticDecision { + KeepTarget, + TakeSource, + Candidate, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FacetResolution { + pub facet: FacetSelector, + pub decision: SemanticDecision, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticReviewRequest { + pub source: Id, + pub resolutions: Vec, + /// Exact previous selectors deliberately invalidated after correspondence changes. + #[serde(default)] + pub reset: Vec, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum ImpactRecord { + Element(Element), + Relation(Relation), + Artifact(ArtifactLink), + Diagram(Diagram), + Trace(Trace), +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticImpact { + pub object: Id, + pub before: Option, + pub after: Option, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticTransitionRequest { + pub transition: TransitionRequest, + /// Source object → chosen existing trace, required when the pair has several traces. + #[serde(default)] + pub traces: BTreeMap, + /// Canonical OA→SA reference correspondence, including historical deleted IDs. + #[serde(default)] + pub references: BTreeMap, + #[serde(default)] + pub reviews: Vec, + /// Final typed candidate records. Identity and aliases cannot change here. + #[serde(default)] + pub candidates: Vec, + #[serde(default)] + pub accepted_impacts: Vec, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticReviewPreview { + pub source: Id, + pub target: Id, + pub facets: Vec, + pub acknowledged: Vec, + pub retired: Vec, + pub linked_impacts: Vec, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticTransitionPlan { + pub base_fingerprint: String, + pub request: SemanticTransitionRequest, + pub transition: TransitionPlan, + pub reviews: Vec, + pub impacts: Vec, + pub conflicts: Vec, + pub commands: Vec, + pub prepared_fingerprint: Option, +} +impl SemanticTransitionRequest { + pub fn new(mut transition: TransitionRequest) -> Self { + // Semantic headers have their own explicit facets. Existing prose clients + // continue to use the original request and planner unchanged. + transition.fields.clear(); + transition.resolutions.clear(); + Self { + transition, + traces: BTreeMap::new(), + references: BTreeMap::new(), + reviews: vec![], + candidates: vec![], + accepted_impacts: vec![], + } + } +} + +fn canonical(direction: TransitionDirection, source: Id, target: Id) -> (Id, Id) { + match direction { + TransitionDirection::OaToSa => (source, target), + TransitionDirection::SaToOa => (target, source), + } +} +fn trace_for(p: &Project, oa: Id, sa: Id, selected: Option) -> Result { + if let Some(id) = selected { + return p + .traces + .get(&id) + .filter(|t| t.oa == oa && t.sa == sa) + .cloned() + .ok_or( + "Selected trace does not match the explicit source/target correspondence".into(), + ); + } + let traces: Vec<_> = p + .traces + .values() + .filter(|t| t.oa == oa && t.sa == sa) + .collect(); + if traces.len() != 1 { + return Err( + "Semantic review requires exactly one explicit trace for this correspondence".into(), + ); + } + Ok(traces[0].clone()) +} +fn old_facts(t: &Trace) -> SemanticBaseline { + t.baseline + .as_ref() + .and_then(|b| b.semantic.clone()) + .unwrap_or_default() +} +fn side(fact: &FacetReview, layer: Layer) -> serde_json::Value { + let (oa, sa) = sides(fact); + match layer { + Layer::Oa => oa, + Layer::Sa => sa, + } +} +fn target_slot( + target: Id, + selector: &FacetSelector, + layer: Layer, +) -> Result, String> { + let has_target = match selector { + FacetSelector::Member { keys, .. } + | FacetSelector::Binding(keys) + | FacetSelector::Child(keys) + | FacetSelector::Incident { keys, .. } + | FacetSelector::Artifact { keys, .. } => chosen(keys, layer).is_some(), + FacetSelector::Annotation(keys) => chosen(keys, layer).is_some(), + FacetSelector::Literal(keys) => chosen(keys, layer).is_some(), + _ => true, + }; + if !has_target { + return Ok(None); + } + fn single(key: &KeyPair, layer: Layer) -> KeyPair { + KeyPair { + oa: None, + sa: chosen(key, layer).cloned(), + } + } + let selector = match selector { + FacetSelector::Member { collection, keys } => FacetSelector::Member { + collection: *collection, + keys: single(keys, layer), + }, + FacetSelector::Literal(keys) => FacetSelector::Literal(single(keys, layer)), + FacetSelector::Annotation(keys) => FacetSelector::Annotation(single(keys, layer)), + FacetSelector::Binding(keys) => FacetSelector::Binding(single(keys, layer)), + FacetSelector::Child(keys) => { + return Ok(Some(( + *chosen(keys, layer).ok_or("Target child mapping missing")?, + FacetSelector::Header(HeaderFacet::Parent), + ))); + } + FacetSelector::Incident { keys, end } => { + return Ok(Some(( + *chosen(keys, layer).ok_or("Target incident mapping missing")?, + FacetSelector::Relation(match end { + IncidentEnd::Source => RelationFacet::Source, + IncidentEnd::Target => RelationFacet::Target, + }), + ))); + } + FacetSelector::Artifact { keys, facet } => { + return Ok(Some(( + *chosen(keys, layer).ok_or("Missing target artifact")?, + FacetSelector::Artifact { + keys: single(keys, layer), + facet: if *facet == ArtifactFacet::Presence { + ArtifactFacet::Owner + } else { + *facet + }, + }, + ))); + } + v => v.clone(), + }; + Ok(Some((target, selector))) +} +fn agree( + slots: &mut BTreeMap<(Id, FacetSelector), serde_json::Value>, + slot: (Id, FacetSelector), + value: serde_json::Value, +) -> Result<(), String> { + if slots.get(&slot).is_some_and(|old| *old != value) { + return Err(format!( + "Contradictory resolutions for canonical target slot {slot:?}" + )); + } + slots.insert(slot, value); + Ok(()) +} +fn slot_observation( + project: &Project, + slot: &(Id, FacetSelector), +) -> Result { + if let FacetSelector::Artifact { facet, .. } = &slot.1 { + let artifact = project.artifact_links.get(&slot.0); + if *facet == ArtifactFacet::Presence { + return Ok(serde_json::json!({"Value":artifact.is_some()})); + } + let Some(artifact) = artifact else { + return Ok(serde_json::Value::String("NotApplicable".into())); + }; + let record = serde_json::to_value(artifact).map_err(|e| e.to_string())?; + let field = match facet { + ArtifactFacet::Presence => unreachable!(), + ArtifactFacet::Name => "name", + ArtifactFacet::Description => "description", + ArtifactFacet::Owner => "owner", + ArtifactFacet::Relation => "relation", + ArtifactFacet::Uri => "uri", + ArtifactFacet::Revision => "revision", + ArtifactFacet::Fragment => "fragment", + }; + return Ok(serde_json::json!({"Value":record[field]})); + } + Ok(side( + &capture_observation(project, slot.0, slot.0, &slot.1)?, + Layer::Sa, + )) +} +fn audit_slots( + project: &Project, + slots: &BTreeMap<(Id, FacetSelector), serde_json::Value>, +) -> Result<(), String> { + for (slot, desired) in slots { + if slot_observation(project, slot)? != *desired { + return Err(format!( + "Contradictory final value for selected canonical slot {slot:?}" + )); + } + } + Ok(()) +} +fn apply_canonical_slot( + project: &mut Project, + slot: &(Id, FacetSelector), + desired: &serde_json::Value, +) -> Result<(), String> { + if is_constructor(&slot.1) { + return Ok(()); + } + if desired.as_str() == Some("NotApplicable") { + if matches!( + slot.1, + FacetSelector::Artifact { + facet: ArtifactFacet::Owner, + .. + } + ) { + project.artifact_links.remove(&slot.0); + } + return Ok(()); + } + let owner = if matches!(slot.1, FacetSelector::Artifact { .. }) { + project + .artifact_links + .get(&slot.0) + .ok_or("Missing agreed artifact candidate")? + .owner + } else { + slot.0 + }; + let fact = capture_observation(project, owner, owner, &slot.1)?; + let fact = replace_observation(fact, Layer::Sa, desired.clone())?; + write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false) +} +fn target_only(selector: &FacetSelector, source: Layer, target: Layer) -> bool { + match selector { + FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => { + chosen(keys, source).is_none() && chosen(keys, target).is_some() + } + FacetSelector::Literal(keys) => { + chosen(keys, source).is_none() && chosen(keys, target).is_some() + } + FacetSelector::Annotation(keys) => { + chosen(keys, source).is_none() && chosen(keys, target).is_some() + } + _ => false, + } +} +fn preserve_constructor_members( + project: &mut Project, + target: Id, + group: &FacetSelector, + initial_facts: &[FacetDrift], + baseline: &SemanticBaseline, + source_layer: Layer, + target_layer: Layer, +) -> Result<(), String> { + for fact in initial_facts { + if !constructor_contains(group, &fact.selector) + || !target_only(&fact.selector, source_layer, target_layer) + { + continue; + } + let slot = target_slot(target, &fact.selector, target_layer)?; + let was_corresponding = baseline.facets.iter().any(|old| { + !target_only(&old.selector(), source_layer, target_layer) + && target_slot(target, &old.selector(), target_layer).ok() == Some(slot.clone()) + }); + if was_corresponding { + continue; + } + write_fact( + project, + target, + &fact.current, + target_layer, + target_layer, + false, + )?; + } + Ok(()) +} +fn install_candidate(p: &mut Project, record: &SemanticRecord) -> Result<(), String> { + match record { + SemanticRecord::Element(e) => { + let old = p + .elements + .get(&e.id) + .ok_or("Candidate element is not mapped")?; + if e.alias != old.alias || e.layer() != old.layer() { + return Err("Candidate cannot change alias or layer".into()); + } + p.elements.insert(e.id, e.clone()); + } + SemanticRecord::Relation(r) => { + let old = p + .relations + .get(&r.id) + .ok_or("Candidate relation is not mapped")?; + if r.alias != old.alias + || std::mem::discriminant(&r.body) != std::mem::discriminant(&old.body) + { + return Err("Relation identity/kind changes require explicit mapping".into()); + } + p.relations.insert(r.id, r.clone()); + } + SemanticRecord::Artifact(a) => { + if p.artifact_links + .get(&a.id) + .is_some_and(|old| old.alias != a.alias) + { + return Err("Artifact candidate cannot change identity alias".into()); + } + p.artifact_links.insert(a.id, a.clone()); + } + } + Ok(()) +} +fn edits(before: &Project, after: &Project) -> Vec { + let mut commands = vec![]; + for (id, e) in &after.elements { + if before.elements.get(id) != Some(e) { + commands.push(if before.elements.contains_key(id) { + Command::EditElement(*id, e.clone()) + } else { + Command::CreateElement(e.clone()) + }); + } + } + for (id, r) in &after.relations { + if before.relations.get(id) != Some(r) { + commands.push(if before.relations.contains_key(id) { + Command::EditRelation(*id, r.clone()) + } else { + Command::CreateRelation(r.clone()) + }); + } + } + for (id, a) in &after.artifact_links { + if before.artifact_links.get(id) != Some(a) { + commands.push(if before.artifact_links.contains_key(id) { + Command::EditArtifact(*id, a.clone()) + } else { + Command::CreateArtifact(a.clone()) + }); + } + } + for id in before.artifact_links.keys() { + if !after.artifact_links.contains_key(id) { + commands.push(Command::Delete(*id)); + } + } + for (id, t) in &after.traces { + if before.traces.get(id) != Some(t) { + commands.push(if before.traces.contains_key(id) { + Command::EditTrace(*id, t.clone()) + } else { + Command::CreateTrace(t.clone()) + }); + } + } + commands +} +fn impacts(before: &Project, after: &Project) -> Vec { + let mut result = vec![]; + macro_rules! records { + ($field:ident,$variant:ident) => { + for id in before + .$field + .keys() + .chain(after.$field.keys()) + .copied() + .collect::>() + { + let a = before.$field.get(&id); + let b = after.$field.get(&id); + if a != b { + result.push(SemanticImpact { + object: id, + before: a.cloned().map(ImpactRecord::$variant), + after: b.cloned().map(ImpactRecord::$variant), + }); + } + } + }; + } + records!(elements, Element); + records!(relations, Relation); + records!(artifact_links, Artifact); + records!(diagrams, Diagram); + records!(traces, Trace); + result +} + +/// Preview typed semantic decisions on top of the existing mapping/dependency +/// planner. All mutation is eventually one ordinary Command::Batch preparation. +pub fn plan_semantic_transition( + project: &Project, + request: SemanticTransitionRequest, +) -> Result { + let transition = transitions::plan_transition(project, &request.transition)?; + let mut plan = SemanticTransitionPlan { + base_fingerprint: transitions::project_fingerprint(project), + request, + commands: transition.commands.clone(), + transition, + reviews: vec![], + impacts: vec![], + conflicts: vec![], + prepared_fingerprint: None, + }; + plan.conflicts + .extend(plan.transition.conflicts.iter().map(|c| c.message.clone())); + if !plan.conflicts.is_empty() { + return Ok(plan); + } + let initial = prepare_command(project, &Command::Batch(plan.commands.clone()))?.project; + if let Err(error) = build_review(project, &initial, &mut plan) { + plan.conflicts.push(error); + } + Ok(plan) +} +fn build_review( + original: &Project, + initial: &Project, + plan: &mut SemanticTransitionPlan, +) -> Result<(), String> { + let direction = plan.request.transition.direction; + let source_layer = direction.source_layer(); + let target_layer = direction.target_layer(); + let mut references = plan.request.references.clone(); + for c in &plan.transition.changes { + let (oa, sa) = canonical(direction, c.source, c.target); + if references.insert(oa, sa).is_some_and(|old| old != sa) { + return Err("Transition mapping disagrees with frozen correspondence".into()); + } + } + for (oa, sa) in &references { + if initial.layer_of(*oa).is_some_and(|v| v != Layer::Oa) + || initial.layer_of(*sa).is_some_and(|v| v != Layer::Sa) + { + return Err("Reference correspondence must use canonical OA/SA sides".into()); + } + } + let map = directional_map(&references, direction)?; + let mut candidates = initial.clone(); + let mut candidate_ids = BTreeSet::new(); + for candidate in &plan.request.candidates { + if !candidate_ids.insert(candidate.id()) { + return Err("Duplicate candidate record".into()); + } + install_candidate(&mut candidates, candidate)?; + } + let mut slots = BTreeMap::new(); + let mut constructor_proposals: Vec<(Id, FacetSelector, SemanticRecord)> = vec![]; + let mut constructor_actions: Vec<(Id, FacetSelector, SemanticRecord)> = vec![]; + let mut created_artifacts = BTreeMap::new(); + let mut implicit_orders: Vec<(Id, OrderedCollection, Vec)> = vec![]; + let mut seen_sources = BTreeSet::new(); + let mut acknowledgements: Vec<(Id, Id, Id, SemanticBaseline, Vec)> = vec![]; + for request in &plan.request.reviews { + // Every source's desired values are derived from the same frozen project. + // No source observes writes proposed by an earlier source. + let mut expected = initial.clone(); + if !seen_sources.insert(request.source) { + return Err("Duplicate semantic review source".into()); + } + let target = plan + .transition + .changes + .iter() + .find(|c| c.source == request.source) + .map(|c| c.target) + .ok_or("Review source is outside selected transition mappings")?; + let (oa, sa) = canonical(direction, request.source, target); + let mut trace = trace_for( + initial, + oa, + sa, + plan.request.traces.get(&request.source).copied(), + )?; + let mut baseline = old_facts(&trace); + let mut reset_set = BTreeSet::new(); + for reset in &request.reset { + if !reset_set.insert(reset.clone()) + || !baseline.facets.iter().any(|v| v.selector() == *reset) + { + return Err("Reset must name an existing historical selector exactly once".into()); + } + } + baseline + .facets + .retain(|v| !reset_set.contains(&v.selector())); + if let Some(outer) = trace.baseline.as_mut() { + outer.semantic = Some(baseline.clone()); + } + let all = semantic_facets(initial, &trace, &references)?; + let mut acknowledged = vec![]; + let mut retired = vec![]; + let mut resolutions = request.resolutions.iter().collect::>(); + for (i, resolution) in resolutions.iter().enumerate() { + check_keys(&resolution.facet, &references)?; + if let FacetSelector::Artifact { keys, facet } = &resolution.facet + && *facet != ArtifactFacet::Presence + { + for (owner, key) in [(oa, keys.oa), (sa, keys.sa)] { + if key + .and_then(|id| initial.artifact_links.get(&id)) + .is_some_and(|artifact| artifact.owner != owner) + { + return Err("Artifact metadata can only be reviewed on its initially attached owner trace".into()); + } + } + } + capture_facet(initial, oa, sa, &resolution.facet)?; + if resolutions[..i].iter().any(|r| { + keyed_slots_overlap(&r.facet, &resolution.facet) + || constructor_contains(&r.facet, &resolution.facet) + || constructor_contains(&resolution.facet, &r.facet) + }) { + return Err("Duplicate, colliding or overlapping resolution scopes".into()); + } + if baseline.facets.iter().any(|old| { + old.selector() != resolution.facet + && keyed_slots_overlap(&old.selector(), &resolution.facet) + }) { + return Err( + "Reference pairing changed; explicitly reset the affected historical selector" + .into(), + ); + } + for group in [ + FacetSelector::Data(DataFacet::Constructor), + FacetSelector::Port(PortFacet::Constructor), + FacetSelector::Occurrence(OccurrenceFacet::Constructor), + FacetSelector::Occurrence(OccurrenceFacet::MessageKind), + FacetSelector::Event(EventFacet::Constructor), + ] { + if group != resolution.facet + && constructor_contains(&group, &resolution.facet) + && let Some(old) = baseline.facets.iter().find(|f| f.selector() == group) + && let Ok(current) = capture_facet(initial, oa, sa, &group) + && sides(old) != sides(¤t) + { + return Err("Constructor changed since review; resolve its explicit group before descendant facets".into()); + } + } + } + resolutions.sort_by_key(|r| { + if is_constructor(&r.facet) { + 0 + } else if matches!(r.facet, FacetSelector::Order(_)) { + 2 + } else { + 1 + } + }); + for resolution in resolutions { + let selected = &resolution.facet; + let current = capture_facet(initial, oa, sa, selected)?; + if is_constructor(selected) { + let old = baseline.facets.iter().find(|v| v.selector() == *selected); + let current_source = side(¤t, source_layer); + let current_target = side(¤t, target_layer); + let candidate_fact = capture_facet(&candidates, oa, sa, selected)?; + let proposed = match resolution.decision { + SemanticDecision::KeepTarget => current_target.clone(), + SemanticDecision::TakeSource => current_source.clone(), + SemanticDecision::Candidate => side(&candidate_fact, target_layer), + }; + let historical_switch = old.is_some_and(|old| sides(old) != sides(¤t)); + if proposed == current_target + && !historical_switch + && (old.is_some() || current_source == current_target) + { + return Err( + "No constructor switch; review descendant facets individually".into(), + ); + } + if resolution.decision != SemanticDecision::KeepTarget { + let mut supplied = semantic_record( + if resolution.decision == SemanticDecision::TakeSource { + initial + } else { + &candidates + }, + if resolution.decision == SemanticDecision::TakeSource { + request.source + } else { + target + }, + ) + .ok_or("Missing constructor candidate")?; + if resolution.decision == SemanticDecision::TakeSource + && !matches!( + selected, + FacetSelector::Occurrence(OccurrenceFacet::MessageKind) + | FacetSelector::State(StateFacet::Kind) + ) + { + if let SemanticRecord::Element(e) = &mut supplied { + e.parent = None; + } + remap_record(&mut supplied, &map)?; + } + copy_constructor(&mut expected, target, &supplied, selected)?; + if resolution.decision == SemanticDecision::TakeSource + && proposed == current_target + { + preserve_constructor_members( + &mut expected, + target, + selected, + &all, + &baseline, + source_layer, + target_layer, + )?; + } + } + let observed = if resolution.decision == SemanticDecision::KeepTarget { + initial + } else { + &expected + }; + let local_record = + semantic_record(observed, target).ok_or("Missing constructor proposal")?; + constructor_proposals.push((target, selected.clone(), local_record.clone())); + if resolution.decision != SemanticDecision::KeepTarget { + constructor_actions.push((target, selected.clone(), local_record)); + } + let refreshed = semantic_facets(observed, &trace, &references)?; + let group_selectors: BTreeSet<_> = refreshed + .iter() + .chain(all.iter()) + .map(|f| f.selector.clone()) + .filter(|f| constructor_contains(selected, f)) + .collect(); + let mut group = vec![]; + for selector in group_selectors { + match capture_facet(observed, oa, sa, &selector) { + Ok(current) => group.push(FacetDrift { + selector, + current, + drift: transitions::Drift::Unreviewed, + }), + Err(_) => { + if let Some(slot) = target_slot(target, &selector, target_layer)? { + agree( + &mut slots, + slot, + serde_json::Value::String("NotApplicable".into()), + )?; + } + retired.push(selector); + } + } + } + for fact in &group { + if let Some(slot) = target_slot(target, &fact.selector, target_layer)? { + agree(&mut slots, slot, side(&fact.current, target_layer))?; + } + } + baseline + .facets + .retain(|f| !constructor_contains(selected, &f.selector())); + acknowledged.extend(group.into_iter().map(|f| f.selector)); + } else { + let from = match resolution.decision { + SemanticDecision::TakeSource => source_layer, + _ => target_layer, + }; + let mut fact = if resolution.decision == SemanticDecision::Candidate { + capture_facet(&candidates, oa, sa, selected)? + } else { + current.clone() + }; + if resolution.decision == SemanticDecision::TakeSource { + fact = remap_fact(&fact, &map, source_layer)?; + } + // A Keep reads the frozen target, never a value written by an earlier source. + if resolution.decision != SemanticDecision::KeepTarget { + if let FacetSelector::Member { + collection: which, + keys, + } = selected + && matches!( + which, + ReferenceCollection::Steps | ReferenceCollection::FragmentBranches + ) + && resolution.decision == SemanticDecision::TakeSource + && let (Some(source_key), Some(target_key)) = + (chosen(keys, source_layer), chosen(keys, target_layer)) + && let Some(source_order) = collection(initial, request.source, *which) + && source_order.contains(source_key) + { + let mut target_order = + collection(&expected, target, *which).ok_or("Missing target order")?; + let order_facet = FacetSelector::Order(match which { + ReferenceCollection::Steps => OrderedCollection::Steps, + _ => OrderedCollection::FragmentBranches, + }); + if !target_order.contains(target_key) + && !request.resolutions.iter().any(|r| { + r.facet == order_facet && r.decision != SemanticDecision::KeepTarget + }) + { + let position = + source_order.iter().position(|id| id == source_key).unwrap(); + let anchor = |id: &Id| { + map.get(id).and_then(|mapped| { + target_order.iter().position(|id| id == mapped) + }) + }; + let left = source_order[..position].iter().rev().find_map(anchor); + let right = source_order[position + 1..].iter().find_map(anchor); + let at=match (left,right) { + (Some(a),Some(b)) if a+1==b=>b, + (Some(a),None) if a+1==target_order.len()=>a+1, + (None,Some(0))=>0, + (None,None) if target_order.is_empty()=>0, + _=>return Err("Ordered member insertion has no unique unchanged anchor; review an explicit final target order".into()), + }; + target_order.insert(at, *target_key); + set_collection(&mut expected, target, *which, target_order)?; + } + } + if let FacetSelector::Artifact { + keys, + facet: ArtifactFacet::Presence, + } = selected + && let Some(id) = chosen(keys, target_layer) + && !expected.artifact_links.contains_key(id) + && let Some(candidate) = candidates.artifact_links.get(id) + { + for field in [ + ArtifactFacet::Name, + ArtifactFacet::Description, + ArtifactFacet::Owner, + ArtifactFacet::Relation, + ArtifactFacet::Uri, + ArtifactFacet::Revision, + ArtifactFacet::Fragment, + ] { + if !request.resolutions.iter().any(|r| { + r.facet + == (FacetSelector::Artifact { + keys: keys.clone(), + facet: field, + }) + }) { + return Err("Creating an artifact requires explicit review of each introduced metadata facet".into()); + } + } + expected.artifact_links.insert(*id, candidate.clone()); + if let Some(old) = created_artifacts.insert(*id, candidate.clone()) + && old != *candidate + { + return Err("Conflicting complete artifact creation candidates".into()); + } + } + if resolution.decision == SemanticDecision::Candidate + && matches!( + selected, + FacetSelector::Child(_) + | FacetSelector::Incident { .. } + | FacetSelector::Artifact { + facet: ArtifactFacet::Owner, + .. + } + ) + { + match selected { + FacetSelector::Child(keys) => { + let key = + chosen(keys, target_layer).ok_or("Missing target child")?; + expected + .elements + .get_mut(key) + .ok_or("Missing target child")? + .parent = candidates + .elements + .get(key) + .ok_or("Missing child candidate")? + .parent; + } + FacetSelector::Incident { keys, end } => { + let key = + chosen(keys, target_layer).ok_or("Missing target relation")?; + let proposed = candidates + .relations + .get(key) + .ok_or("Missing endpoint candidate")? + .body + .endpoints(); + let current = expected + .relations + .get_mut(key) + .ok_or("Missing target relation")?; + let (a, b) = current.body.endpoints(); + match end { + IncidentEnd::Source => { + current.body.set_endpoints(proposed.0, b) + } + IncidentEnd::Target => { + current.body.set_endpoints(a, proposed.1) + } + } + } + FacetSelector::Artifact { + keys, + facet: ArtifactFacet::Owner, + } => { + let key = + chosen(keys, target_layer).ok_or("Missing target artifact")?; + let current = expected + .artifact_links + .get_mut(key) + .ok_or("Missing target artifact")?; + if current.owner != target { + return Err( + "Move the artifact from its currently attached owner trace" + .into(), + ); + } + current.owner = candidates + .artifact_links + .get(key) + .ok_or("Missing artifact owner candidate")? + .owner; + } + _ => unreachable!(), + } + } else { + // Attachment was authorized on the immutable initial + // snapshot. Another selected facet may have moved this + // same artifact within the local proposal. + let write_target = match selected { + FacetSelector::Artifact { keys, facet } + if *facet != ArtifactFacet::Presence => + { + chosen(keys, target_layer) + .and_then(|id| expected.artifact_links.get(id)) + .map_or(target, |artifact| artifact.owner) + } + _ => target, + }; + write_fact( + &mut expected, + write_target, + &fact, + from, + target_layer, + resolution.decision == SemanticDecision::TakeSource, + )?; + } + if let FacetSelector::Artifact { + keys, + facet: ArtifactFacet::Presence, + } = selected + && let Some(id) = chosen(keys, target_layer) + && initial.artifact_links.contains_key(id) + && !expected.artifact_links.contains_key(id) + { + for field in [ + ArtifactFacet::Name, + ArtifactFacet::Description, + ArtifactFacet::Owner, + ArtifactFacet::Relation, + ArtifactFacet::Uri, + ArtifactFacet::Revision, + ArtifactFacet::Fragment, + ] { + let removed = FacetSelector::Artifact { + keys: keys.clone(), + facet: field, + }; + if let Some(slot) = target_slot(target, &removed, target_layer)? { + agree( + &mut slots, + slot, + serde_json::Value::String("NotApplicable".into()), + )?; + } + if capture_facet(&expected, oa, sa, &removed).is_err() { + baseline.facets.retain(|v| v.selector() != removed); + retired.push(removed); + } else { + acknowledged.push(removed); + } + } + } + } + if let Some(slot) = target_slot(target, selected, target_layer)? { + let observed_project = if resolution.decision == SemanticDecision::KeepTarget { + initial + } else { + &expected + }; + let observed = slot_observation(observed_project, &slot)?; + agree(&mut slots, slot, observed)?; + } + acknowledged.push(selected.clone()); + } + } + for ordered in [ + OrderedCollection::Steps, + OrderedCollection::FragmentBranches, + ] { + let which = super::catalog::order_collection(ordered); + if let Some(order) = collection(&expected, target, which) + && collection(initial, target, which).as_ref() != Some(&order) + && !request + .resolutions + .iter() + .any(|r| r.facet == FacetSelector::Order(ordered)) + { + implicit_orders.push((target, ordered, order)); + } + } + plan.reviews.push(SemanticReviewPreview { + source: request.source, + target, + facets: all, + acknowledged: acknowledged.clone(), + retired, + linked_impacts: linked_semantic_impacts(initial, &trace, &references)?, + }); + acknowledgements.push((oa, sa, trace.id, baseline, acknowledged)); + } + // A constructor owns every canonical branch slot mentioned by any proposal, + // including a member introduced by another source. Compare all of these + // against its independently derived record before any mutation is applied. + let selected_slots = slots.keys().cloned().collect::>(); + for (target, group, record) in &constructor_proposals { + let mut proposal = initial.clone(); + install_candidate(&mut proposal, record)?; + for slot in &selected_slots { + if slot.0 == *target && constructor_contains(group, &slot.1) { + agree(&mut slots, slot.clone(), slot_observation(&proposal, slot)?)?; + } + } + } + let mut expected = initial.clone(); + constructor_actions.sort_by_key(|(_, group, _)| { + matches!( + group, + FacetSelector::Occurrence(OccurrenceFacet::MessageKind) + ) + }); + for (target, group, record) in &constructor_actions { + copy_constructor(&mut expected, *target, record, group)?; + } + for (id, artifact) in created_artifacts { + expected.artifact_links.insert(id, artifact); + } + let mut merged = slots.iter().collect::>(); + merged.sort_by_key(|((_, facet), _)| matches!(facet, FacetSelector::Order(_))); + for (slot, desired) in merged { + apply_canonical_slot(&mut expected, slot, desired)?; + } + let mut merged_orders = BTreeMap::new(); + for (target, ordered, mut order) in implicit_orders { + if slots.contains_key(&(target, FacetSelector::Order(ordered))) { + continue; + } + let which = super::catalog::order_collection(ordered); + let actual = + collection(&expected, target, which).ok_or("Missing merged ordered collection")?; + let surviving = actual.iter().copied().collect::>(); + order.retain(|id| surviving.contains(id)); + if surviving != order.iter().copied().collect::>() { + return Err("Combined ordered membership needs an explicit final target order".into()); + } + if let Some(previous) = merged_orders.insert((target, ordered), order.clone()) + && previous != order + { + return Err("Contradictory anchored orders need an explicit final target order".into()); + } + } + for ((target, ordered), order) in merged_orders { + set_collection( + &mut expected, + target, + super::catalog::order_collection(ordered), + order, + )?; + } + audit_slots(&expected, &slots)?; + for candidate in &plan.request.candidates { + if semantic_record(&expected, candidate.id()).as_ref() != Some(candidate) { + return Err(format!( + "Candidate {} changes an unselected facet", + candidate.id() + )); + } + } + let mut commands = plan.commands.clone(); + commands.extend(edits(initial, &expected)); + let prepared = + prepare_command(original, &Command::Batch(commands.clone())).map_err(|e| e.to_string())?; + plan.impacts = impacts(&expected, &prepared.project); + if plan.impacts.len() != plan.request.accepted_impacts.len() + || plan + .impacts + .iter() + .any(|impact| !plan.request.accepted_impacts.contains(impact)) + { + return Err("Prepared candidate induces additional changes; explicitly review every exact before/after impact".into()); + } + audit_slots(&prepared.project, &slots)?; + let mut final_project = prepared.project.clone(); + for (oa, sa, trace_id, mut baseline, acknowledged) in acknowledgements { + for selector in acknowledged { + let fact = capture_facet(&final_project, oa, sa, &selector)?; + baseline.facets.retain(|v| v.selector() != selector); + baseline.facets.push(fact); + } + baseline.facets.sort_by_key(FacetReview::selector); + validate_semantic_baseline(&baseline).map_err(|e| e.join("; "))?; + let mut trace = trace_for(&final_project, oa, sa, Some(trace_id))?; + let mut outer = trace.baseline.clone().unwrap_or(TraceBaseline { + oa: final_project + .properties(oa) + .ok_or("Missing OA properties")?, + sa: final_project + .properties(sa) + .ok_or("Missing SA properties")?, + semantic: None, + }); + outer.semantic = Some(baseline); + trace.baseline = Some(outer); + final_project.traces.insert(trace.id, trace); + } + commands.extend(edits(&prepared.project, &final_project)); + let result = + prepare_command(original, &Command::Batch(commands.clone())).map_err(|e| e.to_string())?; + if result.project != final_project { + return Err("Final preparation changed the reviewed candidate".into()); + } + plan.prepared_fingerprint = Some(transitions::project_fingerprint(&result.project)); + plan.commands = commands; + Ok(()) +} +pub fn prepare_semantic_transition( + project: &Project, + plan: &SemanticTransitionPlan, +) -> Result { + if transitions::project_fingerprint(project) != plan.base_fingerprint { + return Err(ModelError("Semantic transition preview is stale".into())); + } + if !plan.conflicts.is_empty() { + return Err(ModelError( + "Resolve semantic transition conflicts before applying".into(), + )); + } + let result = prepare_command(project, &Command::Batch(plan.commands.clone()))?; + if Some(transitions::project_fingerprint(&result.project)) != plan.prepared_fingerprint { + return Err(ModelError( + "Prepared semantic candidate differs from reviewed result".into(), + )); + } + Ok(result) +} +pub fn apply_semantic_transition( + project: &Project, + plan: &SemanticTransitionPlan, +) -> Result { + prepare_semantic_transition(project, plan).map(|p| p.project) +} diff --git a/crates/oa-core/src/semantic/types.rs b/crates/oa-core/src/semantic/types.rs new file mode 100644 index 00000000..56adee52 --- /dev/null +++ b/crates/oa-core/src/semantic/types.rs @@ -0,0 +1,255 @@ +use crate::model::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum Observed { + NotApplicable, + Value(T), +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Reviewed { + pub oa: Observed, + pub sa: Observed, +} +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeyPair { + pub oa: Option, + pub sa: Option, +} +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AnnotationKey { + pub path: Vec, + pub field: Id, +} +impl From<&MessageValue> for AnnotationKey { + fn from(value: &MessageValue) -> Self { + Self { + path: value.path.clone(), + field: value.field, + } + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DataConstructor { + Boolean, + Integer, + Real, + String, + Enumeration, + Record, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PortConstructor { + FunctionInput, + FunctionOutput, + Component, + Legacy, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum OccurrenceConstructor { + Exchange, + Action, + Control, + SequenceReference, + ScenarioUse, + State, + Fragment, + Branch, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EventConstructor { + Signal, + Time, + Change, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MessageConstructor { + Asynchronous, + Call, + Reply, + Create, + Delete, + Timer, +} + +// This finite catalog is also consumed by the typed historical leaf visitor. +// Each selector fixes its payload type; there is no arbitrary value/path schema. +macro_rules! scalar_families { + ($m:ident) => { $m! { + Header, HeaderFacet, HeaderReview { + Name: String, Description: String, Parent: Option + } + ExchangeItem, ExchangeItemFacet, ExchangeItemReview { + Nature: ExchangeItemKind, Mechanism: ExchangeMechanism, Supertype: Option + } + Interface, InterfaceFacet, InterfaceReview { Supertype: Option } + Data, DataFacet, DataReview { + Constructor: DataConstructor, + IntegerMinimum: Option, IntegerMaximum: Option, + RealMinimum: Option, RealMaximum: Option, + StringMinimumLength: u32, StringMaximumLength: Option, + RecordSupertype: Option, Default: Option, Unit: String + } + Field, FieldFacet, FieldReview { + Type: TypeRef, Minimum: u32, Maximum: Option, Unit: String, + Direction: ParameterDirection, Storage: FieldStorage, Default: Option> + } + Port, PortFacet, PortReview { + Constructor: PortConstructor, ComponentKind: ComponentPortKind, + Orientation: ComponentPortOrientation, LegacyDirection: PortDirection, + LegacyInterface: Option + } + Scenario, ScenarioFacet, ScenarioReview { + Viewpoint: ScenarioViewpoint, Context: Option, Precondition: String, Postcondition: String + } + Lifeline, LifelineFacet, LifelineReview { Represented: Option } + Occurrence, OccurrenceFacet, OccurrenceReview { + Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String, + Exchange: Option, Sender: Option, Receiver: Option, + MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option, + Action: Option, ActionLifeline: Option, ControlKind: ControlKind, + Sequence: Option, Scenario: Option, State: Option, + StateLifeline: Option, FragmentOperator: FragmentOperator, BranchGuard: String + } + Event, EventFacet, EventReview { + Constructor: EventConstructor, TimeKind: TimeEventKind, TimeExpression: String, ChangeExpression: String + } + State, StateFacet, StateReview { Kind: StateKind, Entry: String, Exit: String, DoActivity: String } + Constraint, ConstraintFacet, ConstraintReview { Expression: String } + Relation, RelationFacet, RelationReview { + Source: Id, Target: Id, AllocationOrder: u32, Transmission: TransmissionProtocol, + Acquisition: AcquisitionProtocol, SourceRole: String, TargetRole: String, + SourceMinimum: u32, SourceMaximum: Option, TargetMinimum: u32, + TargetMaximum: Option, SourceNavigable: bool, TargetNavigable: bool, + SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String, + SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String, + Guard: String, Effect: String, StateActionPhase: StateActionKind + } + }}; +} +pub(super) use scalar_families; + +macro_rules! define_families { + ($( $family:ident, $selector:ident, $review:ident { $( $leaf:ident: $ty:ty ),* } )*) => { + $( + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] + pub enum $selector { $( $leaf ),* } + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub enum $review { $( $leaf(Reviewed<$ty>) ),* } + )* + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub enum FacetSelector { + $( $family($selector), )* + Member { collection: ReferenceCollection, keys: KeyPair }, + Order(OrderedCollection), + Literal(KeyPair), + Annotation(KeyPair), + Binding(KeyPair), + Child(KeyPair), + Incident { keys: KeyPair, end: IncidentEnd }, + Artifact { keys: KeyPair, facet: ArtifactFacet }, + } + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + #[serde(deny_unknown_fields)] + pub enum FacetReview { + $( $family($review), )* + Member { collection: ReferenceCollection, keys: KeyPair, review: Reviewed }, + Order { collection: OrderedCollection, review: Reviewed> }, + Literal { keys: KeyPair, review: Reviewed }, + Annotation { keys: KeyPair, review: Reviewed> }, + Binding { keys: KeyPair, review: Reviewed> }, + Child { keys: KeyPair, review: Reviewed }, + Incident { keys: KeyPair, end: IncidentEnd, review: Reviewed }, + Artifact { keys: KeyPair, review: ArtifactReview }, + } + impl FacetReview { + pub fn selector(&self) -> FacetSelector { + match self { + $( Self::$family(value) => FacetSelector::$family(match value { + $( $review::$leaf(_) => $selector::$leaf, )* + }), )* + Self::Member { collection, keys, .. } => FacetSelector::Member { collection: *collection, keys: keys.clone() }, + Self::Order { collection, .. } => FacetSelector::Order(*collection), + Self::Literal { keys, .. } => FacetSelector::Literal(keys.clone()), + Self::Annotation { keys, .. } => FacetSelector::Annotation(keys.clone()), + Self::Binding { keys, .. } => FacetSelector::Binding(keys.clone()), + Self::Child { keys, .. } => FacetSelector::Child(keys.clone()), + Self::Incident { keys, end, .. } => FacetSelector::Incident { keys: keys.clone(), end: *end }, + Self::Artifact { keys, review } => FacetSelector::Artifact { keys: keys.clone(), facet: review.facet() }, + } + } + } + }; +} +scalar_families!(define_families); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum ReferenceCollection { + Steps, + FragmentBranches, + FunctionInputItems, + FunctionOutputItems, + SignalItems, + ExchangeItems, + TransitionTriggers, + TransitionEffects, + Constrained, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum OrderedCollection { + Steps, + FragmentBranches, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum IncidentEnd { + Source, + Target, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum ArtifactFacet { + Presence, + Name, + Description, + Owner, + Relation, + Uri, + Revision, + Fragment, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum ArtifactReview { + Presence(Reviewed), + Name(Reviewed), + Description(Reviewed), + Owner(Reviewed), + Relation(Reviewed), + Uri(Reviewed), + Revision(Reviewed>), + Fragment(Reviewed>), +} +impl ArtifactReview { + pub fn facet(&self) -> ArtifactFacet { + match self { + Self::Presence(_) => ArtifactFacet::Presence, + Self::Name(_) => ArtifactFacet::Name, + Self::Description(_) => ArtifactFacet::Description, + Self::Owner(_) => ArtifactFacet::Owner, + Self::Relation(_) => ArtifactFacet::Relation, + Self::Uri(_) => ArtifactFacet::Uri, + Self::Revision(_) => ArtifactFacet::Revision, + Self::Fragment(_) => ArtifactFacet::Fragment, + } + } +} +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SemanticBaseline { + pub facets: Vec, +} diff --git a/crates/oa-core/src/transitions.rs b/crates/oa-core/src/transitions.rs index 07a2a4b8..bf44464b 100644 --- a/crates/oa-core/src/transitions.rs +++ b/crates/oa-core/src/transitions.rs @@ -202,9 +202,15 @@ fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option { return None; } if let Some(shared) = e.shared() - && kind == e.kind() + && (kind == e.kind() || (kind.is_state() && e.kind().is_state())) { let mut shared = shared.clone(); + if let SharedBody::State { + kind: state_kind, .. + } = &mut shared + { + *state_kind = kind.state_kind()?; + } if let SharedBody::Scenario { viewpoint, .. } = &mut shared { *viewpoint = match (*viewpoint, layer) { (ScenarioViewpoint::OperationalActivity, Layer::Sa) => { @@ -927,6 +933,7 @@ pub fn plan_transition( .unwrap_or(TraceBaseline { oa: current_oa.clone(), sa: current_sa.clone(), + semantic: None, }); for field in &reviewed_fields { set(&mut baseline.oa, *field, value(¤t_oa, *field).clone()); @@ -1081,6 +1088,11 @@ pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec bool { + // State kind is a reviewed structural facet of one state family. An existing + // counterpart remains selectable after either side changes its vertex kind. + if source.is_state() && target.is_state() { + return true; + } if matches!( (direction, source, target), ( diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index 5077103a..f6d24f1a 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -876,6 +876,19 @@ pub fn validate(p: &Project) -> Vec { text(&mut out, t.id, "baseline.name", &props.name); text(&mut out, t.id, "baseline.description", &props.description); } + if let Some(semantic) = &b.semantic + && let Err(errors) = crate::semantic::validate_semantic_baseline(semantic) + { + for message in errors { + error( + &mut out, + t.id, + "semantic_baseline", + "baseline.semantic", + message, + ); + } + } } match crate::transitions::trace_drift(p, t) { crate::transitions::Drift::Unchanged => {} diff --git a/crates/oa-core/tests/r3_semantic.rs b/crates/oa-core/tests/r3_semantic.rs new file mode 100644 index 00000000..093f5b4c --- /dev/null +++ b/crates/oa-core/tests/r3_semantic.rs @@ -0,0 +1,1919 @@ +use oa_core::{transitions::*, *}; +use std::collections::BTreeMap; + +fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id { + p.add_element(kind, layer, name, None).unwrap() +} +fn trace(p: &mut Project, oa: Id, sa: Id) -> Id { + let id = Id::new_v4(); + p.traces.insert( + id, + Trace { + id, + alias: format!("trace_{}", id.simple()), + name: "Review".into(), + description: String::new(), + oa, + sa, + kind: TraceKind::Manual, + baseline: Some(TraceBaseline { + oa: p.properties(oa).unwrap(), + sa: p.properties(sa).unwrap(), + semantic: None, + }), + }, + ); + id +} +fn request( + oa: Id, + sa: Id, + facets: Vec<(FacetSelector, SemanticDecision)>, +) -> SemanticTransitionRequest { + let mut transition = TransitionRequest::new(TransitionDirection::OaToSa, vec![oa]); + transition.mappings.push(TransitionMapping { + source: oa, + target: Some(sa), + target_kind: None, + }); + let mut request = SemanticTransitionRequest::new(transition); + request.reviews.push(SemanticReviewRequest { + source: oa, + resolutions: facets + .into_iter() + .map(|(facet, decision)| FacetResolution { facet, decision }) + .collect(), + reset: vec![], + }); + request +} +fn applied(p: &Project, r: SemanticTransitionRequest) -> Project { + let plan = plan_semantic_transition(p, r).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + apply_semantic_transition(p, &plan).unwrap() +} +fn data(p: &mut Project, id: Id, min: i64, max: i64, unit: &str) { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Integer { + minimum: Some(min), + maximum: Some(max), + }, + default: None, + unit: unit.into(), + }; +} +#[test] +fn independent_bounds_keep_take_and_stale_prepare() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::DataType, Layer::Oa, "bounded"); + let sa = add(&mut p, Kind::DataType, Layer::Sa, "bounded"); + data(&mut p, oa, 0, 10, "m"); + data(&mut p, sa, 0, 10, "m"); + let t = trace(&mut p, oa, sa); + let min = FacetSelector::Data(DataFacet::IntegerMinimum); + let max = FacetSelector::Data(DataFacet::IntegerMaximum); + p = applied( + &p, + request( + oa, + sa, + vec![ + (min.clone(), SemanticDecision::KeepTarget), + (max.clone(), SemanticDecision::KeepTarget), + ], + ), + ); + data(&mut p, oa, 2, 12, "m"); + data(&mut p, sa, 0, 10, "cm"); + let plan = plan_semantic_transition( + &p, + request(oa, sa, vec![(min.clone(), SemanticDecision::KeepTarget)]), + ) + .unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let q = apply_semantic_transition(&p, &plan).unwrap(); + let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap(); + assert_eq!( + facts.iter().find(|f| f.selector == min).unwrap().drift, + Drift::Unchanged + ); + assert_eq!( + facts.iter().find(|f| f.selector == max).unwrap().drift, + Drift::OaChanged + ); + assert_eq!( + facts + .iter() + .find(|f| f.selector == FacetSelector::Data(DataFacet::Unit)) + .unwrap() + .drift, + Drift::Unreviewed + ); + let taken = applied( + &q, + request(oa, sa, vec![(max.clone(), SemanticDecision::TakeSource)]), + ); + assert!( + matches!(taken.elements[&sa].shared(),Some(SharedBody::DataType{definition:DataTypeDefinition::Integer{minimum:Some(0),maximum:Some(12)},unit,..}) if unit=="cm") + ); + let mut stale = p.clone(); + stale.name.push('x'); + assert!(prepare_semantic_transition(&stale, &plan).is_err()); + assert_eq!(apply_semantic_transition(&p, &plan).unwrap(), q); +} +#[test] +fn constructors_expand_into_independent_facts_without_outer_prose() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::DataType, Layer::Oa, "definition"); + let sa = add(&mut p, Kind::DataType, Layer::Sa, "definition"); + data(&mut p, oa, 1, 9, "m"); + *p.elements.get_mut(&sa).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Real { + minimum: None, + maximum: Some(20.), + }, + default: Some(DataValue::Real(1.)), + unit: "cm".into(), + }; + let t = trace(&mut p, oa, sa); + let group = FacetSelector::Data(DataFacet::Constructor); + p = applied( + &p, + request(oa, sa, vec![(group.clone(), SemanticDecision::TakeSource)]), + ); + let base = p.traces[&t] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap(); + assert!( + base.facets + .iter() + .any(|f| f.selector() == FacetSelector::Data(DataFacet::IntegerMaximum)) + ); + assert!( + base.facets + .iter() + .any(|f| f.selector() == FacetSelector::Data(DataFacet::Default)) + ); + assert!( + !base + .facets + .iter() + .any(|f| f.selector() == FacetSelector::Data(DataFacet::Unit)) + ); + assert!(matches!(p.elements[&sa].shared(),Some(SharedBody::DataType{unit,..}) if unit=="cm")); + let bad = plan_semantic_transition( + &p, + request(oa, sa, vec![(group, SemanticDecision::KeepTarget)]), + ) + .unwrap(); + assert!(!bad.conflicts.is_empty()); +} +#[test] +fn enum_members_preserve_target_only_and_no_whole_list_overwrite() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::DataType, Layer::Oa, "statuses"); + let sa = add(&mut p, Kind::DataType, Layer::Sa, "statuses"); + for (id, literals) in [ + (oa, vec!["Ready", "Paused"]), + (sa, vec!["Ready", "Maintenance"]), + ] { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Enumeration { + literals: literals.into_iter().map(str::to_string).collect(), + }, + default: None, + unit: String::new(), + }; + } + trace(&mut p, oa, sa); + let r = request( + oa, + sa, + vec![ + ( + FacetSelector::Literal(KeyPair { + oa: Some("Paused".into()), + sa: Some("Paused".into()), + }), + SemanticDecision::TakeSource, + ), + ( + FacetSelector::Literal(KeyPair { + oa: None, + sa: Some("Maintenance".into()), + }), + SemanticDecision::TakeSource, + ), + ], + ); + let q = applied(&p, r); + assert!( + matches!(q.elements[&sa].shared(),Some(SharedBody::DataType{definition:DataTypeDefinition::Enumeration{literals},..}) if literals==&vec!["Ready".to_string(),"Maintenance".into(),"Paused".into()]) + ); +} +#[test] +fn wrong_family_both_na_and_duplicate_resolutions_rejected_atomically() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::DataType, Layer::Oa, "boolean"); + let sa = add(&mut p, Kind::DataType, Layer::Sa, "boolean"); + trace(&mut p, oa, sa); + for facet in [ + FacetSelector::State(StateFacet::Entry), + FacetSelector::Data(DataFacet::IntegerMinimum), + ] { + let r = request(oa, sa, vec![(facet, SemanticDecision::KeepTarget)]); + let plan = plan_semantic_transition(&p, r).unwrap(); + assert!(!plan.conflicts.is_empty()); + assert!(apply_semantic_transition(&p, &plan).is_err()); + } + let facet = FacetSelector::Data(DataFacet::Unit); + let plan = plan_semantic_transition( + &p, + request( + oa, + sa, + vec![ + (facet.clone(), SemanticDecision::KeepTarget), + (facet, SemanticDecision::TakeSource), + ], + ), + ) + .unwrap(); + assert!(!plan.conflicts.is_empty()); +} +#[test] +fn historical_reference_remap_preserves_text_and_deleted_reference_observations() { + let old = Id::new_v4(); + let new = Id::new_v4(); + let mut base = SemanticBaseline { + facets: vec![ + FacetReview::Field(FieldReview::Type(Reviewed { + oa: Observed::Value(TypeRef::Named(old)), + sa: Observed::Value(TypeRef::Boolean), + })), + FacetReview::Data(DataReview::Unit(Reviewed { + oa: Observed::Value(old.to_string()), + sa: Observed::Value("m".into()), + })), + ], + }; + assert!(validate_semantic_baseline(&base).is_ok()); + visit_semantic_references(&mut base, |id| { + if *id == old { + *id = new; + } + }); + assert_eq!( + base.facets[0], + FacetReview::Field(FieldReview::Type(Reviewed { + oa: Observed::Value(TypeRef::Named(new)), + sa: Observed::Value(TypeRef::Boolean) + })) + ); + assert_eq!( + base.facets[1], + FacetReview::Data(DataReview::Unit(Reviewed { + oa: Observed::Value(old.to_string()), + sa: Observed::Value("m".into()) + })) + ); +} +#[test] +fn same_target_member_keys_cannot_last_write_win() { + let a = Id::new_v4(); + let b = Id::new_v4(); + let c = Id::new_v4(); + let review = Reviewed { + oa: Observed::Value(true), + sa: Observed::Value(false), + }; + let base = SemanticBaseline { + facets: vec![ + FacetReview::Member { + collection: ReferenceCollection::TransitionTriggers, + keys: KeyPair { + oa: Some(a), + sa: Some(c), + }, + review: review.clone(), + }, + FacetReview::Member { + collection: ReferenceCollection::TransitionTriggers, + keys: KeyPair { + oa: Some(b), + sa: Some(c), + }, + review, + }, + ], + }; + assert!(validate_semantic_baseline(&base).is_err()); +} +#[test] +fn source_and_target_semantic_headers_can_be_reviewed_in_reverse() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::Activity, Layer::Oa, "OA"); + let sa = add(&mut p, Kind::Function, Layer::Sa, "SA"); + trace(&mut p, oa, sa); + let mut r = request( + oa, + sa, + vec![( + FacetSelector::Header(HeaderFacet::Name), + SemanticDecision::TakeSource, + )], + ); + r.transition.direction = TransitionDirection::SaToOa; + r.transition.sources = vec![sa]; + r.transition.mappings = vec![TransitionMapping { + source: sa, + target: Some(oa), + target_kind: None, + }]; + r.reviews[0].source = sa; + let q = applied(&p, r); + assert_eq!(q.elements[&oa].name, "SA"); +} + +fn owned(p: &mut Project, kind: Kind, layer: Layer, name: &str, parent: Option) -> Id { + p.add_element(kind, layer, name, parent).unwrap() +} +fn set(p: &mut Project, id: Id, body: SharedBody) { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body; +} + +fn field(p: &mut Project, layer: Layer, owner: Id, name: &str, type_ref: TypeRef, max: u32) -> Id { + let id = owned(p, Kind::DataField, layer, name, Some(owner)); + set( + p, + id, + SharedBody::DataField { + type_ref, + minimum: 0, + maximum: Some(max), + unit: String::new(), + direction: ParameterDirection::Unspecified, + storage: FieldStorage::Value, + default: None, + }, + ); + id +} +fn record(p: &mut Project, layer: Layer, name: &str, supertype: Option) -> Id { + let id = owned(p, Kind::DataType, layer, name, None); + set( + p, + id, + SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + default: None, + unit: String::new(), + }, + ); + id +} + +fn occurrence(p: &mut Project, layer: Layer, owner: Id, name: &str, body: OccurrenceBody) -> Id { + let id = owned(p, Kind::Occurrence, layer, name, Some(owner)); + set( + p, + id, + SharedBody::Occurrence { + body, + precondition: String::new(), + postcondition: String::new(), + }, + ); + p.elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + id +} + +struct Payload { + message: Id, + record: Id, + leaf: Id, + first: AnnotationKey, + second: AnnotationKey, +} +fn payload(p: &mut Project, layer: Layer, prefix: &str) -> Payload { + let coord = record(p, layer, &format!("{prefix} Coordinate"), None); + let leaf = field( + p, + layer, + coord, + &format!("{prefix} latitude"), + TypeRef::Real, + 1, + ); + let item = owned( + p, + Kind::ExchangeItem, + layer, + &format!("{prefix} Item"), + None, + ); + let origin = field( + p, + layer, + item, + &format!("{prefix} origin"), + TypeRef::Named(coord), + 1, + ); + let destination = field( + p, + layer, + item, + &format!("{prefix} destination"), + TypeRef::Named(coord), + 1, + ); + let a = owned( + p, + if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }, + layer, + &format!("{prefix} A"), + None, + ); + let b = owned( + p, + if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }, + layer, + &format!("{prefix} B"), + None, + ); + let exchange = p.relate( + if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }, + &format!("{prefix} exchange"), + a, + b, + ); + match &mut p.relations.get_mut(&exchange).unwrap().body { + RelationBody::Interaction { items, .. } + | RelationBody::FunctionalExchange { items, .. } => items.push(item), + _ => unreachable!(), + } + if let RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } = p.relations[&exchange].body + { + for id in [source_port, target_port] { + match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() { + SharedBody::Port { + contract: PortContract::FunctionInput { incoming_items }, + .. + } => incoming_items.push(item), + SharedBody::Port { + contract: PortContract::FunctionOutput { outgoing_items }, + .. + } => outgoing_items.push(item), + _ => unreachable!(), + } + } + } + let scenario = owned( + p, + Kind::Scenario, + layer, + &format!("{prefix} scenario"), + None, + ); + let first = AnnotationKey { + path: vec![origin], + field: leaf, + }; + let second = AnnotationKey { + path: vec![destination], + field: leaf, + }; + let message = occurrence( + p, + layer, + scenario, + &format!("{prefix} message"), + OccurrenceBody::Exchange { + exchange: Some(exchange), + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![ + MessageValue { + path: first.path.clone(), + field: leaf, + expression: "first old".into(), + }, + MessageValue { + path: second.path.clone(), + field: leaf, + expression: "second independent".into(), + }, + ], + }, + ); + Payload { + message, + record: coord, + leaf, + first, + second, + } +} +fn values_mut(p: &mut Project, id: Id) -> &mut Vec { + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = p.elements.get_mut(&id).unwrap().shared_mut() + else { + panic!() + }; + values +} + +struct Use { + use_id: Id, + referenced: Id, + alternate: Id, + extra_referenced: Id, +} +fn scenario_use(p: &mut Project, layer: Layer, prefix: &str) -> Use { + let caller = owned(p, Kind::Scenario, layer, &format!("{prefix} caller"), None); + let callee = owned(p, Kind::Scenario, layer, &format!("{prefix} callee"), None); + let mut line = |owner, name: &str| { + owned( + p, + Kind::Lifeline, + layer, + &format!("{prefix} {name}"), + Some(owner), + ) + }; + let referenced = line(callee, "referenced"); + let extra_referenced = line(callee, "extra referenced"); + let old_local = line(caller, "local"); + let alternate = line(caller, "alternate"); + let extra_local = line(caller, "extra local"); + let use_id = occurrence( + p, + layer, + caller, + &format!("{prefix} use"), + OccurrenceBody::ScenarioUse { + scenario: Some(callee), + bindings: vec![ + LifelineBinding { + referenced, + local: old_local, + }, + LifelineBinding { + referenced: extra_referenced, + local: extra_local, + }, + ], + }, + ); + Use { + use_id, + referenced, + alternate, + extra_referenced, + } +} +fn bindings_mut(p: &mut Project, id: Id) -> &mut Vec { + let Some(SharedBody::Occurrence { + body: OccurrenceBody::ScenarioUse { bindings, .. }, + .. + }) = p.elements.get_mut(&id).unwrap().shared_mut() + else { + panic!() + }; + bindings +} + +#[test] +fn atomic_annotation_full_path_updates_only_selected_row() { + let mut p = Project::empty(); + let a = payload(&mut p, Layer::Oa, "oa"); + let b = payload(&mut p, Layer::Sa, "sa"); + trace(&mut p, a.message, b.message); + values_mut(&mut p, a.message)[0].expression = "new first".into(); + let retained = values_mut(&mut p, b.message)[1].clone(); + let selector = FacetSelector::Annotation(KeyPair { + oa: Some(a.first.clone()), + sa: Some(b.first.clone()), + }); + let mut r = request( + a.message, + b.message, + vec![(selector, SemanticDecision::TakeSource)], + ); + r.references.extend( + a.first + .path + .iter() + .copied() + .zip(b.first.path.iter().copied()), + ); + r.references.insert(a.leaf, b.leaf); + let mut q = applied(&p, r); + let rows = values_mut(&mut q, b.message); + assert_eq!(rows.len(), 2); + assert!(rows.contains(&retained)); + assert!( + rows.iter() + .any(|v| AnnotationKey::from(v) == b.first && v.expression == "new first") + ); + assert!( + rows.iter() + .any(|v| AnnotationKey::from(v) == b.second && v.expression == retained.expression) + ); +} +#[test] +fn atomic_binding_updates_local_and_rejects_pair_collision_and_remapping_without_reset() { + let mut p = Project::empty(); + let a = scenario_use(&mut p, Layer::Oa, "oa"); + let b = scenario_use(&mut p, Layer::Sa, "sa"); + trace(&mut p, a.use_id, b.use_id); + bindings_mut(&mut p, a.use_id)[0].local = a.alternate; + let retained = bindings_mut(&mut p, b.use_id)[1].clone(); + let selector = FacetSelector::Binding(KeyPair { + oa: Some(a.referenced), + sa: Some(b.referenced), + }); + let mut r = request( + a.use_id, + b.use_id, + vec![(selector.clone(), SemanticDecision::TakeSource)], + ); + r.references = BTreeMap::from([(a.referenced, b.referenced), (a.alternate, b.alternate)]); + let mut q = applied(&p, r.clone()); + assert!(bindings_mut(&mut q, b.use_id).contains(&retained)); + assert!( + bindings_mut(&mut q, b.use_id) + .iter() + .any(|v| v.referenced == b.referenced && v.local == b.alternate) + ); + let collided = FacetSelector::Binding(KeyPair { + oa: Some(a.extra_referenced), + sa: Some(b.referenced), + }); + let mut bad = r.clone(); + bad.references.insert(a.extra_referenced, b.referenced); + bad.reviews[0].resolutions.push(FacetResolution { + facet: collided, + decision: SemanticDecision::KeepTarget, + }); + assert!( + !plan_semantic_transition(&p, bad) + .unwrap() + .conflicts + .is_empty() + ); + let mapped = FacetSelector::Binding(KeyPair { + oa: Some(a.referenced), + sa: Some(b.extra_referenced), + }); + r.references.insert(a.referenced, b.extra_referenced); + r.reviews[0].resolutions = vec![FacetResolution { + facet: mapped.clone(), + decision: SemanticDecision::KeepTarget, + }]; + assert!( + !plan_semantic_transition(&q, r.clone()) + .unwrap() + .conflicts + .is_empty() + ); + r.reviews[0].reset = vec![selector]; + q = applied(&q, r); + let t = q.traces.values().find(|t| t.oa == a.use_id).unwrap(); + let base = t.baseline.as_ref().unwrap().semantic.as_ref().unwrap(); + assert_eq!(base.facets.len(), 1); + assert_eq!(base.facets[0].selector(), mapped); +} +#[test] +fn ordered_member_requires_anchor_or_explicit_order_and_preserves_target_only() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::Process, Layer::Oa, "process"); + let sa = add(&mut p, Kind::FunctionalChain, Layer::Sa, "chain"); + let a = occurrence( + &mut p, + Layer::Oa, + oa, + "a", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let c = occurrence( + &mut p, + Layer::Oa, + oa, + "c", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let b = occurrence( + &mut p, + Layer::Oa, + oa, + "b", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let ta = occurrence( + &mut p, + Layer::Sa, + sa, + "a", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let tc = occurrence( + &mut p, + Layer::Sa, + sa, + "c", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let tb = occurrence( + &mut p, + Layer::Sa, + sa, + "b", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + let extra = occurrence( + &mut p, + Layer::Sa, + sa, + "local", + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ); + *p.elements.get_mut(&oa).unwrap().steps_mut().unwrap() = vec![a, c, b]; + *p.elements.get_mut(&sa).unwrap().steps_mut().unwrap() = vec![ta, extra, tb]; + let t = trace(&mut p, oa, sa); + let member = FacetSelector::Member { + collection: ReferenceCollection::Steps, + keys: KeyPair { + oa: Some(c), + sa: Some(tc), + }, + }; + let mut r = request(oa, sa, vec![(member.clone(), SemanticDecision::TakeSource)]); + r.references = BTreeMap::from([(a, ta), (b, tb), (c, tc)]); + assert!( + !plan_semantic_transition(&p, r.clone()) + .unwrap() + .conflicts + .is_empty() + ); + r.reviews[0].resolutions.push(FacetResolution { + facet: FacetSelector::Order(OrderedCollection::Steps), + decision: SemanticDecision::Candidate, + }); + let mut candidate = p.elements[&sa].clone(); + *candidate.steps_mut().unwrap() = vec![ta, extra, tc, tb]; + r.candidates.push(SemanticRecord::Element(candidate)); + let q = applied(&p, r); + assert_eq!(q.elements[&sa].steps().unwrap(), &vec![ta, extra, tc, tb]); + let facts = q.traces[&t] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap(); + assert_eq!(facts.facets.len(), 2); +} +#[test] +fn prepared_inherited_annotation_cleanup_must_be_explicitly_accepted() { + let mut p = Project::empty(); + let a = payload(&mut p, Layer::Oa, "oa"); + let b = payload(&mut p, Layer::Sa, "sa"); + for (layer, part) in [(Layer::Oa, &a), (Layer::Sa, &b)] { + let parent = record(&mut p, layer, "base", None); + p.elements.get_mut(&part.leaf).unwrap().parent = Some(parent); + set( + &mut p, + part.record, + SharedBody::DataType { + definition: DataTypeDefinition::Record { + supertype: Some(parent), + }, + default: None, + unit: String::new(), + }, + ); + } + trace(&mut p, a.record, b.record); + let mut changed = p.elements[&a.record].clone(); + set_record_supertype(&mut changed, None); + p = prepare_command(&p, &Command::EditElement(a.record, changed)) + .unwrap() + .project; + assert!(values_mut(&mut p, a.message).is_empty()); + assert_eq!(values_mut(&mut p, b.message).len(), 2); + let mut r = request( + a.record, + b.record, + vec![( + FacetSelector::Data(DataFacet::RecordSupertype), + SemanticDecision::TakeSource, + )], + ); + let plan = plan_semantic_transition(&p, r.clone()).unwrap(); + assert!(!plan.conflicts.is_empty()); + assert_eq!(plan.impacts.len(), 1); + assert_eq!(plan.impacts[0].object, b.message); + assert!(apply_semantic_transition(&p, &plan).is_err()); + r.accepted_impacts = plan.impacts; + let mut q = applied(&p, r); + assert!(values_mut(&mut q, b.message).is_empty()); +} +fn set_record_supertype(e: &mut Element, supertype: Option) { + let Some(SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype: s }, + .. + }) = e.shared_mut() + else { + panic!() + }; + *s = supertype; +} + +#[test] +fn repeated_keep_of_unequal_constructor_pair_cannot_swallow_bound_drift() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::DataType, Layer::Oa, "integer"); + let sa = add(&mut p, Kind::DataType, Layer::Sa, "real"); + data(&mut p, oa, 0, 10, "m"); + set( + &mut p, + sa, + SharedBody::DataType { + definition: DataTypeDefinition::Real { + minimum: Some(0.), + maximum: Some(20.), + }, + default: None, + unit: "m".into(), + }, + ); + let t = trace(&mut p, oa, sa); + let selector = FacetSelector::Data(DataFacet::Constructor); + p = applied( + &p, + request( + oa, + sa, + vec![(selector.clone(), SemanticDecision::KeepTarget)], + ), + ); + data(&mut p, oa, 0, 12, "m"); + let plan = plan_semantic_transition( + &p, + request( + oa, + sa, + vec![(selector.clone(), SemanticDecision::KeepTarget)], + ), + ) + .unwrap(); + assert!( + plan.conflicts + .iter() + .any(|c| c.contains("No constructor switch")) + ); + let facts = semantic_facets(&p, &p.traces[&t], &BTreeMap::new()).unwrap(); + assert_eq!( + facts + .iter() + .find(|f| f.selector == FacetSelector::Data(DataFacet::IntegerMaximum)) + .unwrap() + .drift, + Drift::OaChanged + ); + let converted = applied( + &p, + request(oa, sa, vec![(selector, SemanticDecision::TakeSource)]), + ); + assert!(matches!( + converted.elements[&sa].shared(), + Some(SharedBody::DataType { + definition: DataTypeDefinition::Integer { + maximum: Some(12), + .. + }, + .. + }) + )); +} + +fn state_pair(p: &mut Project) -> (Id, Id) { + let mut pair = vec![]; + for layer in [Layer::Oa, Layer::Sa] { + let machine = add(p, Kind::StateMachine, layer, "machine"); + let region = owned(p, Kind::Region, layer, "region", Some(machine)); + pair.push(owned(p, Kind::State, layer, "ready", Some(region))); + } + (pair[0], pair[1]) +} +#[test] +fn changed_state_kind_remains_reviewable_in_both_directions() { + let mut p = Project::empty(); + let (oa, sa) = state_pair(&mut p); + trace(&mut p, oa, sa); + let Some(SharedBody::State { kind, .. }) = p.elements.get_mut(&oa).unwrap().shared_mut() else { + panic!() + }; + *kind = StateKind::Final; + let selector = FacetSelector::State(StateFacet::Kind); + let kept = applied( + &p, + request( + oa, + sa, + vec![(selector.clone(), SemanticDecision::KeepTarget)], + ), + ); + assert_eq!(kept.elements[&sa].kind(), Kind::State); + let taken = applied( + &p, + request( + oa, + sa, + vec![(selector.clone(), SemanticDecision::TakeSource)], + ), + ); + assert_eq!(taken.elements[&sa].kind(), Kind::Final); + let mut reverse = request(oa, sa, vec![(selector, SemanticDecision::TakeSource)]); + reverse.transition.direction = TransitionDirection::SaToOa; + reverse.transition.sources = vec![sa]; + reverse.transition.mappings = vec![TransitionMapping { + source: sa, + target: Some(oa), + target_kind: None, + }]; + reverse.reviews[0].source = sa; + let back = applied(&p, reverse); + assert_eq!(back.elements[&oa].kind(), Kind::State); +} + +#[test] +fn constructor_retirement_cannot_override_other_sources_kept_bound_in_either_order() { + let mut p = Project::empty(); + let a = add(&mut p, Kind::DataType, Layer::Oa, "real"); + let b = add(&mut p, Kind::DataType, Layer::Oa, "integer"); + let target = add(&mut p, Kind::DataType, Layer::Sa, "real"); + for id in [a, target] { + set( + &mut p, + id, + SharedBody::DataType { + definition: DataTypeDefinition::Real { + minimum: Some(0.), + maximum: Some(20.), + }, + default: None, + unit: String::new(), + }, + ); + } + data(&mut p, b, 0, 10, ""); + trace(&mut p, a, target); + trace(&mut p, b, target); + let mut r = request( + a, + target, + vec![( + FacetSelector::Data(DataFacet::RealMaximum), + SemanticDecision::KeepTarget, + )], + ); + r.transition.sources.push(b); + r.transition.mappings.push(TransitionMapping { + source: b, + target: Some(target), + target_kind: None, + }); + r.reviews.push(SemanticReviewRequest { + source: b, + resolutions: vec![FacetResolution { + facet: FacetSelector::Data(DataFacet::Constructor), + decision: SemanticDecision::TakeSource, + }], + reset: vec![], + }); + for reverse in [false, true] { + let mut r = r.clone(); + if reverse { + r.reviews.reverse(); + } + let plan = plan_semantic_transition(&p, r).unwrap(); + assert!( + plan.conflicts.iter().any(|c| c.contains("Contradictory")), + "{:?}", + plan.conflicts + ); + assert!(apply_semantic_transition(&p, &plan).is_err()); + } +} +#[test] +fn semantic_rollup_does_not_claim_unchanged_when_only_unit_is_reviewed() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::DataType, Layer::Oa, "integer"); + let sa = add(&mut p, Kind::DataType, Layer::Sa, "integer"); + data(&mut p, oa, 0, 10, "m"); + data(&mut p, sa, 0, 10, "m"); + let t = trace(&mut p, oa, sa); + p = applied( + &p, + request( + oa, + sa, + vec![( + FacetSelector::Data(DataFacet::Unit), + SemanticDecision::KeepTarget, + )], + ), + ); + data(&mut p, oa, 0, 12, "m"); + let status = trace_semantic_drift(&p, &p.traces[&t], &BTreeMap::new()).unwrap(); + assert_eq!(status.own, Drift::Unreviewed); + assert!(status.own_unreviewed); +} +fn artifact(p: &mut Project, owner: Id, name: &str) -> Id { + let id = Id::new_v4(); + p.artifact_links.insert( + id, + ArtifactLink { + id, + alias: format!("artifact_{}", id.simple()), + name: name.into(), + description: String::new(), + owner, + relation: ArtifactRelation::References, + uri: "https://example.test/requirement".into(), + revision: None, + fragment: None, + }, + ); + id +} +#[test] +fn artifact_metadata_is_attached_and_cannot_last_write_through_two_owner_traces() { + let mut p = Project::empty(); + let a = add(&mut p, Kind::Activity, Layer::Oa, "a"); + let b = add(&mut p, Kind::Activity, Layer::Oa, "b"); + let ta = add(&mut p, Kind::Function, Layer::Sa, "a"); + let tb = add(&mut p, Kind::Function, Layer::Sa, "b"); + trace(&mut p, a, ta); + trace(&mut p, b, tb); + let aa = artifact(&mut p, a, "A"); + let ab = artifact(&mut p, b, "B"); + let at = artifact(&mut p, ta, "retained"); + let mut r = request( + a, + ta, + vec![( + FacetSelector::Artifact { + keys: KeyPair { + oa: Some(aa), + sa: Some(at), + }, + facet: ArtifactFacet::Name, + }, + SemanticDecision::TakeSource, + )], + ); + r.transition.sources.push(b); + r.transition.mappings.push(TransitionMapping { + source: b, + target: Some(tb), + target_kind: None, + }); + r.references = BTreeMap::from([(aa, at), (ab, at)]); + r.reviews.push(SemanticReviewRequest { + source: b, + resolutions: vec![FacetResolution { + facet: FacetSelector::Artifact { + keys: KeyPair { + oa: Some(ab), + sa: Some(at), + }, + facet: ArtifactFacet::Name, + }, + decision: SemanticDecision::TakeSource, + }], + reset: vec![], + }); + for reverse in [false, true] { + let mut r = r.clone(); + if reverse { + r.reviews.reverse(); + } + let plan = plan_semantic_transition(&p, r).unwrap(); + assert!(!plan.conflicts.is_empty()); + assert!(apply_semantic_transition(&p, &plan).is_err()); + } + let mut same = request( + a, + ta, + vec![( + FacetSelector::Artifact { + keys: KeyPair { + oa: Some(aa), + sa: Some(at), + }, + facet: ArtifactFacet::Name, + }, + SemanticDecision::TakeSource, + )], + ); + same.references.insert(aa, at); + let q = applied(&p, same); + assert_eq!(q.artifact_links[&at].name, "A"); +} + +#[test] +fn child_membership_and_parent_resolutions_agree_by_actual_child_slot() { + let mut p = Project::empty(); + let owner = add(&mut p, Kind::Activity, Layer::Oa, "owner"); + let child = owned(&mut p, Kind::Activity, Layer::Oa, "child", Some(owner)); + let target = add(&mut p, Kind::Function, Layer::Sa, "owner"); + let target_child = add(&mut p, Kind::Function, Layer::Sa, "child"); + let other = add(&mut p, Kind::Function, Layer::Sa, "other"); + trace(&mut p, owner, target); + trace(&mut p, child, target_child); + let mut r = request( + owner, + target, + vec![( + FacetSelector::Child(KeyPair { + oa: Some(child), + sa: Some(target_child), + }), + SemanticDecision::TakeSource, + )], + ); + r.transition.sources.push(child); + r.transition.mappings.push(TransitionMapping { + source: child, + target: Some(target_child), + target_kind: None, + }); + r.reviews.push(SemanticReviewRequest { + source: child, + resolutions: vec![FacetResolution { + facet: FacetSelector::Header(HeaderFacet::Parent), + decision: SemanticDecision::TakeSource, + }], + reset: vec![], + }); + let q = applied(&p, r.clone()); + assert_eq!(q.elements[&target_child].parent, Some(target)); + r.reviews[1].resolutions[0].decision = SemanticDecision::Candidate; + let mut candidate = p.elements[&target_child].clone(); + candidate.parent = Some(other); + r.candidates.push(SemanticRecord::Element(candidate)); + for reverse in [false, true] { + let mut r = r.clone(); + if reverse { + r.reviews.reverse(); + } + let plan = plan_semantic_transition(&p, r).unwrap(); + assert!( + plan.conflicts.iter().any(|c| c.contains("Contradictory")), + "{:?}", + plan.conflicts + ); + } +} +#[test] +fn state_guard_and_deleted_trigger_membership_preserve_target_only_trigger() { + let mut p = Project::empty(); + let (oa, sa) = state_pair(&mut p); + let a = p.relate(RelationKind::StateTransition, "self", oa, oa); + let b = p.relate(RelationKind::StateTransition, "self", sa, sa); + let old = add(&mut p, Kind::Event, Layer::Oa, "old"); + let retained = add(&mut p, Kind::Event, Layer::Sa, "old"); + let local = add(&mut p, Kind::Event, Layer::Sa, "local"); + for (id, triggers) in [(a, vec![old]), (b, vec![retained, local])] { + let RelationBody::StateTransition { + triggers: t, guard, .. + } = &mut p.relations.get_mut(&id).unwrap().body + else { + panic!() + }; + *t = triggers; + *guard = "old".into(); + } + let t = trace(&mut p, a, b); + let selector = FacetSelector::Member { + collection: ReferenceCollection::TransitionTriggers, + keys: KeyPair { + oa: Some(old), + sa: Some(retained), + }, + }; + let mut r = request(a, b, vec![(selector.clone(), SemanticDecision::KeepTarget)]); + r.references.insert(old, retained); + p = applied(&p, r.clone()); + p = prepare_command(&p, &Command::Delete(old)).unwrap().project; + let RelationBody::StateTransition { guard, .. } = &mut p.relations.get_mut(&a).unwrap().body + else { + panic!() + }; + *guard = "ready".into(); + r.reviews[0].resolutions = vec![ + FacetResolution { + facet: selector, + decision: SemanticDecision::TakeSource, + }, + FacetResolution { + facet: FacetSelector::Relation(RelationFacet::Guard), + decision: SemanticDecision::TakeSource, + }, + ]; + let q = applied(&p, r); + assert!( + matches!(&q.relations[&b].body,RelationBody::StateTransition{guard,triggers,..} if guard=="ready"&&triggers==&vec![local]) + ); + assert!(q.elements.contains_key(&retained)); + assert!(q.traces[&t].baseline.as_ref().unwrap().semantic.is_some()); +} +#[test] +fn artifact_creation_requires_reviewed_metadata_and_deletion_retires_exact_facts() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::Activity, Layer::Oa, "oa"); + let sa = add(&mut p, Kind::Function, Layer::Sa, "sa"); + let t = trace(&mut p, oa, sa); + let a = artifact(&mut p, oa, "requirement"); + let b = Id::new_v4(); + let keys = KeyPair { + oa: Some(a), + sa: Some(b), + }; + let mut candidate = p.artifact_links[&a].clone(); + candidate.id = b; + candidate.alias = format!("artifact_{}", b.simple()); + candidate.owner = sa; + let mut r = request( + oa, + sa, + vec![( + FacetSelector::Artifact { + keys: keys.clone(), + facet: ArtifactFacet::Presence, + }, + SemanticDecision::TakeSource, + )], + ); + r.references.insert(a, b); + r.candidates + .push(SemanticRecord::Artifact(candidate.clone())); + assert!( + !plan_semantic_transition(&p, r.clone()) + .unwrap() + .conflicts + .is_empty() + ); + for facet in [ + ArtifactFacet::Name, + ArtifactFacet::Description, + ArtifactFacet::Owner, + ArtifactFacet::Relation, + ArtifactFacet::Uri, + ArtifactFacet::Revision, + ArtifactFacet::Fragment, + ] { + r.reviews[0].resolutions.push(FacetResolution { + facet: FacetSelector::Artifact { + keys: keys.clone(), + facet, + }, + decision: SemanticDecision::Candidate, + }); + } + p = applied(&p, r); + assert_eq!(p.artifact_links[&b], candidate); + assert_eq!( + p.traces[&t] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap() + .facets + .len(), + 8 + ); + p = prepare_command(&p, &Command::Delete(a)).unwrap().project; + let mut removal = request( + oa, + sa, + vec![( + FacetSelector::Artifact { + keys, + facet: ArtifactFacet::Presence, + }, + SemanticDecision::TakeSource, + )], + ); + removal.references.insert(a, b); + let plan = plan_semantic_transition(&p, removal).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + assert_eq!(plan.reviews[0].retired.len(), 7); + let q = apply_semantic_transition(&p, &plan).unwrap(); + assert!(!q.artifact_links.contains_key(&b)); + assert_eq!( + q.traces[&t] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap() + .facets + .len(), + 1 + ); +} + +#[test] +fn semantic_batch_is_one_undo_and_replanning_acknowledged_values_is_noop() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::DataType, Layer::Oa, "source"); + let sa = add(&mut p, Kind::DataType, Layer::Sa, "target"); + data(&mut p, oa, 0, 12, "m"); + data(&mut p, sa, 0, 10, "ft"); + trace(&mut p, oa, sa); + let r = request( + oa, + sa, + vec![ + ( + FacetSelector::Header(HeaderFacet::Name), + SemanticDecision::TakeSource, + ), + ( + FacetSelector::Data(DataFacet::IntegerMaximum), + SemanticDecision::TakeSource, + ), + ( + FacetSelector::Data(DataFacet::Unit), + SemanticDecision::KeepTarget, + ), + ], + ); + let plan = plan_semantic_transition(&p, r.clone()).unwrap(); + assert!(plan.conflicts.is_empty()); + let mut session = Session::new(p.clone()); + session.execute(Command::Batch(plan.commands)).unwrap(); + let accepted = session.project().clone(); + assert_eq!(session.revision(), 1); + session.undo(); + assert_eq!(session.project(), &p); + session.redo(); + assert_eq!(session.project(), &accepted); + let repeated = plan_semantic_transition(&accepted, r).unwrap(); + assert!(repeated.conflicts.is_empty()); + assert_eq!( + apply_semantic_transition(&accepted, &repeated).unwrap(), + accepted + ); + let mut invalid = request( + oa, + sa, + vec![( + FacetSelector::Data(DataFacet::IntegerMinimum), + SemanticDecision::Candidate, + )], + ); + let mut candidate = accepted.elements[&sa].clone(); + let Some(SharedBody::DataType { + definition: DataTypeDefinition::Integer { minimum, .. }, + .. + }) = candidate.shared_mut() + else { + panic!() + }; + *minimum = Some(100); + invalid.candidates.push(SemanticRecord::Element(candidate)); + let rejected = plan_semantic_transition(&accepted, invalid).unwrap(); + assert!(!rejected.conflicts.is_empty()); + assert!(apply_semantic_transition(&accepted, &rejected).is_err()); + assert_eq!(session.project(), &accepted); +} +#[test] +fn explicit_trace_selection_keeps_independent_trace_baselines() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::Activity, Layer::Oa, "source"); + let sa = add(&mut p, Kind::Function, Layer::Sa, "target"); + let first = trace(&mut p, oa, sa); + let second = Id::new_v4(); + let mut extra = p.traces[&first].clone(); + extra.id = second; + extra.alias = format!("trace_{}", second.simple()); + extra.kind = TraceKind::Refinement; + extra.baseline = None; + p.traces.insert(second, extra); + let mut r = request( + oa, + sa, + vec![( + FacetSelector::Header(HeaderFacet::Name), + SemanticDecision::KeepTarget, + )], + ); + assert!( + !plan_semantic_transition(&p, r.clone()) + .unwrap() + .conflicts + .is_empty() + ); + r.traces.insert(oa, second); + let q = applied(&p, r); + assert!( + q.traces[&first] + .baseline + .as_ref() + .unwrap() + .semantic + .is_none() + ); + assert_eq!( + q.traces[&second] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap() + .facets + .len(), + 1 + ); +} + +fn enumeration(p: &mut Project, id: Id, literals: &[&str]) { + set( + p, + id, + SharedBody::DataType { + definition: DataTypeDefinition::Enumeration { + literals: literals.iter().map(|s| s.to_string()).collect(), + }, + default: None, + unit: String::new(), + }, + ); +} +#[test] +fn constructor_union_conflicts_with_another_sources_new_member_in_either_order() { + let mut p = Project::empty(); + let a = add(&mut p, Kind::DataType, Layer::Oa, "enumeration"); + let b = add(&mut p, Kind::DataType, Layer::Oa, "boolean"); + let target = add(&mut p, Kind::DataType, Layer::Sa, "enumeration"); + enumeration(&mut p, a, &["New"]); + enumeration(&mut p, target, &[]); + trace(&mut p, a, target); + trace(&mut p, b, target); + let mut r = request( + a, + target, + vec![( + FacetSelector::Literal(KeyPair { + oa: Some("New".into()), + sa: Some("New".into()), + }), + SemanticDecision::TakeSource, + )], + ); + r.transition.sources.push(b); + r.transition.mappings.push(TransitionMapping { + source: b, + target: Some(target), + target_kind: None, + }); + r.reviews.push(SemanticReviewRequest { + source: b, + resolutions: vec![FacetResolution { + facet: FacetSelector::Data(DataFacet::Constructor), + decision: SemanticDecision::TakeSource, + }], + reset: vec![], + }); + for reverse in [false, true] { + let mut r = r.clone(); + if reverse { + r.reviews.reverse(); + } + let plan = plan_semantic_transition(&p, r).unwrap(); + assert!( + plan.conflicts.iter().any(|c| c.contains("Contradictory")), + "{:?}", + plan.conflicts + ); + } +} +#[test] +fn same_target_constructor_preserves_local_members_and_reset_precedes_expansion() { + for ready in [false, true] { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::DataType, Layer::Oa, "integer"); + let sa = add(&mut p, Kind::DataType, Layer::Sa, "enumeration"); + data(&mut p, oa, 0, 10, ""); + enumeration( + &mut p, + sa, + if ready { + &["Ready", "Local"] + } else { + &["Local"] + }, + ); + let t = trace(&mut p, oa, sa); + let constructor = FacetSelector::Data(DataFacet::Constructor); + p = applied( + &p, + request( + oa, + sa, + vec![(constructor.clone(), SemanticDecision::KeepTarget)], + ), + ); + enumeration(&mut p, oa, if ready { &["Ready"] } else { &["New"] }); + let mut r = request(oa, sa, vec![(constructor, SemanticDecision::TakeSource)]); + if ready { + r.reviews[0].reset.push(FacetSelector::Literal(KeyPair { + oa: None, + sa: Some("Ready".into()), + })); + } + let q = applied(&p, r); + assert!( + matches!(q.elements[&sa].shared(),Some(SharedBody::DataType{definition:DataTypeDefinition::Enumeration{literals},..}) if literals.contains(&"Local".to_string())) + ); + if ready { + let base = q.traces[&t] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap(); + assert_eq!(base.facets.iter().filter(|f|matches!(f.selector(),FacetSelector::Literal(KeyPair{sa:Some(ref value),..})if value=="Ready")).count(),1); + } + } +} +#[test] +fn artifact_owner_candidate_moves_link_and_conflicts_with_kept_attachment() { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::Activity, Layer::Oa, "oa"); + let sa = add(&mut p, Kind::Function, Layer::Sa, "sa"); + let other = add(&mut p, Kind::Function, Layer::Sa, "other"); + trace(&mut p, oa, sa); + let a = artifact(&mut p, oa, "source"); + let b = artifact(&mut p, sa, "target"); + let keys = KeyPair { + oa: Some(a), + sa: Some(b), + }; + let mut r = request( + oa, + sa, + vec![( + FacetSelector::Artifact { + keys: keys.clone(), + facet: ArtifactFacet::Owner, + }, + SemanticDecision::Candidate, + )], + ); + r.references.insert(a, b); + let mut candidate = p.artifact_links[&b].clone(); + candidate.owner = other; + r.candidates.push(SemanticRecord::Artifact(candidate)); + let q = applied(&p, r.clone()); + assert_eq!(q.artifact_links[&b].owner, other); + r.reviews[0].resolutions.push(FacetResolution { + facet: FacetSelector::Artifact { + keys, + facet: ArtifactFacet::Presence, + }, + decision: SemanticDecision::KeepTarget, + }); + let plan = plan_semantic_transition(&p, r).unwrap(); + assert!( + plan.conflicts.iter().any(|c| c.contains("Contradictory")), + "{:?}", + plan.conflicts + ); +} + +#[test] +fn shared_definition_drift_is_linked_without_copying_definition_into_field_baseline() { + let mut p = Project::empty(); + let a = add(&mut p, Kind::DataType, Layer::Oa, "type"); + let b = add(&mut p, Kind::DataType, Layer::Sa, "type"); + data(&mut p, a, 0, 10, "m"); + data(&mut p, b, 0, 10, "m"); + trace(&mut p, a, b); + let oa_owner = record(&mut p, Layer::Oa, "record", None); + let sa_owner = record(&mut p, Layer::Sa, "record", None); + let oa = field(&mut p, Layer::Oa, oa_owner, "value", TypeRef::Named(a), 1); + let sa = field(&mut p, Layer::Sa, sa_owner, "value", TypeRef::Named(b), 1); + let t = trace(&mut p, oa, sa); + p = applied( + &p, + request( + a, + b, + vec![( + FacetSelector::Data(DataFacet::IntegerMaximum), + SemanticDecision::KeepTarget, + )], + ), + ); + let mut r = request( + oa, + sa, + vec![( + FacetSelector::Field(FieldFacet::Type), + SemanticDecision::KeepTarget, + )], + ); + r.references.insert(a, b); + p = applied(&p, r); + data(&mut p, a, 0, 12, "m"); + let impacts = linked_semantic_impacts(&p, &p.traces[&t], &BTreeMap::from([(a, b)])).unwrap(); + assert!(impacts.iter().any(|impact| impact.objects + == KeyPair { + oa: Some(a), + sa: Some(b) + } + && impact.drift == Drift::OaChanged)); + let base = p.traces[&t] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap(); + assert_eq!(base.facets.len(), 1); + assert_eq!( + base.facets[0].selector(), + FacetSelector::Field(FieldFacet::Type) + ); +} + +#[test] +fn combined_ordered_membership_commutes_or_requires_agreed_anchors() { + for insertion in [false, true] { + let mut p = Project::empty(); + let a = add(&mut p, Kind::Process, Layer::Oa, "first"); + let b = add(&mut p, Kind::Process, Layer::Oa, "second"); + let target = add(&mut p, Kind::FunctionalChain, Layer::Sa, "target"); + let mut rows = Vec::new(); + for (owner, layer) in [(a, Layer::Oa), (b, Layer::Oa), (target, Layer::Sa)] { + rows.push(["left", "right", "middle"].map(|name| { + occurrence( + &mut p, + layer, + owner, + name, + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + ) + })); + } + let [left, right, middle] = rows[2]; + *p.elements.get_mut(&a).unwrap().steps_mut().unwrap() = if insertion { + vec![rows[0][0], rows[0][2], rows[0][1]] + } else { + vec![] + }; + *p.elements.get_mut(&b).unwrap().steps_mut().unwrap() = if insertion { + vec![rows[1][2], rows[1][0], rows[1][1]] + } else { + vec![] + }; + *p.elements.get_mut(&target).unwrap().steps_mut().unwrap() = if insertion { + vec![left, right] + } else { + vec![left, middle, right] + }; + trace(&mut p, a, target); + trace(&mut p, b, target); + let facet = |index: usize| FacetSelector::Member { + collection: ReferenceCollection::Steps, + keys: KeyPair { + oa: Some(rows[index][if insertion { 2 } else { index }]), + sa: Some(rows[2][if insertion { 2 } else { index }]), + }, + }; + let mut r = request(a, target, vec![(facet(0), SemanticDecision::TakeSource)]); + r.transition.sources.push(b); + r.transition.mappings.push(TransitionMapping { + source: b, + target: Some(target), + target_kind: None, + }); + r.reviews.push(SemanticReviewRequest { + source: b, + resolutions: vec![FacetResolution { + facet: facet(1), + decision: SemanticDecision::TakeSource, + }], + reset: vec![], + }); + for row in &rows[..2] { + for index in 0..3 { + r.references.insert(row[index], rows[2][index]); + } + } + for reversed in [false, true] { + let mut r = r.clone(); + if reversed { + r.reviews.reverse(); + } + let plan = plan_semantic_transition(&p, r).unwrap(); + if insertion { + assert!( + plan.conflicts + .iter() + .any(|c| c.contains("Contradictory anchored orders")), + "{:?}", + plan.conflicts + ); + } else { + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let q = apply_semantic_transition(&p, &plan).unwrap(); + assert_eq!(q.elements[&target].steps().unwrap(), &vec![middle]); + } + } + } +} + +#[test] +fn target_only_artifact_move_records_owner_and_metadata_in_either_order() { + for rename in [false, true] { + for reversed in [false, true] { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::Activity, Layer::Oa, "oa"); + let sa = add(&mut p, Kind::Function, Layer::Sa, "sa"); + let other = add(&mut p, Kind::Function, Layer::Sa, "other"); + let t = trace(&mut p, oa, sa); + let b = artifact(&mut p, sa, "local"); + let keys = KeyPair { + oa: None, + sa: Some(b), + }; + let owner = FacetSelector::Artifact { + keys: keys.clone(), + facet: ArtifactFacet::Owner, + }; + let name = FacetSelector::Artifact { + keys: keys.clone(), + facet: ArtifactFacet::Name, + }; + let mut r = request(oa, sa, vec![(owner.clone(), SemanticDecision::Candidate)]); + let mut candidate = p.artifact_links[&b].clone(); + candidate.owner = other; + if rename { + candidate.name = "moved local".into(); + r.reviews[0].resolutions.push(FacetResolution { + facet: name.clone(), + decision: SemanticDecision::Candidate, + }); + } + if reversed { + r.reviews[0].resolutions.reverse(); + } + r.candidates + .push(SemanticRecord::Artifact(candidate.clone())); + let q = applied(&p, r); + assert_eq!(q.artifact_links[&b], candidate); + let baseline = q.traces[&t] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap(); + assert!(baseline.facets.iter().any(|f| matches!(f, FacetReview::Artifact { review: ArtifactReview::Owner(Reviewed { oa: Observed::NotApplicable, sa: Observed::Value(id) }), .. } if *id == other))); + assert_eq!(baseline.facets.len(), if rename { 2 } else { 1 }); + let invalid = plan_semantic_transition( + &q, + request(oa, sa, vec![(name, SemanticDecision::KeepTarget)]), + ) + .unwrap(); + assert!( + invalid + .conflicts + .iter() + .any(|c| c.contains("initially attached owner")), + "{:?}", + invalid.conflicts + ); + assert!( + validation::validate(&q) + .iter() + .all(|issue| issue.severity != validation::Severity::Error) + ); + } + } +} + +#[test] +fn retired_source_branch_retains_historical_member_pair_in_both_directions() { + for reverse in [false, true] { + let mut p = Project::empty(); + let oa = add(&mut p, Kind::DataType, Layer::Oa, "oa"); + let sa = add(&mut p, Kind::DataType, Layer::Sa, "sa"); + enumeration(&mut p, oa, &["X"]); + enumeration(&mut p, sa, &["X"]); + let t = trace(&mut p, oa, sa); + let literal = FacetSelector::Literal(KeyPair { + oa: Some("X".into()), + sa: Some("X".into()), + }); + p = applied( + &p, + request( + oa, + sa, + vec![(literal.clone(), SemanticDecision::KeepTarget)], + ), + ); + let (source, target) = if reverse { (sa, oa) } else { (oa, sa) }; + data(&mut p, source, 0, 10, ""); + let mut r = request( + source, + target, + vec![( + FacetSelector::Data(DataFacet::Constructor), + SemanticDecision::KeepTarget, + )], + ); + if reverse { + r.transition.direction = TransitionDirection::SaToOa; + } + let q = applied(&p, r); + let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap(); + let literals = facts + .iter() + .filter(|fact| matches!(fact.selector, FacetSelector::Literal(_))) + .collect::>(); + assert_eq!(literals.len(), 1); + assert_eq!(literals[0].selector, literal); + assert_eq!(literals[0].drift, Drift::Unchanged); + let FacetReview::Literal { review, .. } = &literals[0].current else { + panic!() + }; + assert_eq!( + if reverse { &review.sa } else { &review.oa }, + &Observed::NotApplicable + ); + assert_eq!( + if reverse { &review.oa } else { &review.sa }, + &Observed::Value(true) + ); + } +} diff --git a/crates/oa-core/tests/workflows.rs b/crates/oa-core/tests/workflows.rs index a6759939..c9b5f01d 100644 --- a/crates/oa-core/tests/workflows.rs +++ b/crates/oa-core/tests/workflows.rs @@ -42,6 +42,7 @@ fn make_trace(p: &mut Project, oa: Id, sa: Id) -> Id { baseline: Some(TraceBaseline { oa: p.properties(oa).unwrap(), sa: p.properties(sa).unwrap(), + semantic: None, }), }; p.traces.insert(id, t); diff --git a/harness/DECISIONS.md b/harness/DECISIONS.md index fbdfcc9f..ac91fad3 100644 --- a/harness/DECISIONS.md +++ b/harness/DECISIONS.md @@ -78,3 +78,36 @@ State camera/tool lifetime follows effective machine and diagram identity, even when selection changes through Catalog or Layer outside Behavior. Reset only on actual context change; preserve same-view return. Independent review reproduced the leak and native Windows/Linux tests verified the correction in316dc55. + +## Semantic review proposals and historical admission (2026-09-06) + +R3 stores closed, typed, independently reviewed OA/SA facet pairs. Constructor +facts store discriminators only; explicit group decisions enumerate and retire +their exact branch facts. No persisted recursive value tree, own-record carrier, +review mask or order shadow is introduced. The complete contract is +release-semantic-review-implementation.md. + +Derive every source proposal from the same immutable initial project. Reset old +mapping selectors before deriving scopes; compare all desired canonical target +slots, including constructor removals, before one materialization. Audit selected +values against both that result and the ordinarily prepared project. This prevents +sequential last-write wins, even when one source introduces a member another +constructor removes. Preserve target-only members during same-target-constructor +reconciliation. Artifact attachment and explicit owner changes share the actual +artifact's owner slot; child/incident operations similarly share actual parent or +endpoint slots. Exact prepared cleanup impacts require explicit acknowledgement +and may not contradict a selected Keep/Take. + +Artifact metadata is observed by identified record after authorization against +its initial attachment. An owner move therefore retains the actual resulting +Owner/Name facts, including target-only links; Presence remains owner-relative. +New metadata decisions require the current attached owner trace. Historical paired +selectors survive disappearance of one side and suppress redundant one-sided +projections. A new pairing cannot suppress prior one-sided history without reset. + +Current resolution applicability and historical baseline admission are separate. +New decisions require a compatible current family and an applicable side; historical +typed facts can outlive former constructors/families and deleted references. Their +typed reference visitor supports explicit reidentification but does not add live +deletion dependencies. Derived linked-definition drift remains bounded and exposes +missing review coverage instead of copying a shared definition into every baseline. diff --git a/harness/STATE.md b/harness/STATE.md index 2f9d1780..63808a74 100644 --- a/harness/STATE.md +++ b/harness/STATE.md @@ -120,10 +120,10 @@ Root owns API/OpenAPI/reST, docs, integration and native input. No concurrent wr scenario-value authoring, then full integrated Windows/Linux verification. 2. Finish R6 core/source/desktop, integrate actual REST/schema/reST, independent semantic/correctness/UX review, Windows/Linux checks and real data authoring. -3. Implement R3 in the isolated semantic-review checkout using the selected closed - typed per-facet review representation (C). Current production reconciliation - remains prose-only until implementation is verified. The prior typed-carrier - preference is superseded by release-semantic-review-implementation.md. +3. Integrate the verified R3 core/source checkpoint from the isolated semantic-review + checkout, then complete desktop/server and native/platform acceptance. The closed + typed per-facet representation (C) and its immutable-proposal rule are recorded + in release-semantic-review-implementation.md; this is not release acceptance. 4. Complete direct scenario/process gestures, persistent coverage/filters, Home/recent/recovery, hosted desktop ETag workflows and offline guidance. 5. Fresh final requirements/domain/correctness/native UX/architecture review; full @@ -140,6 +140,75 @@ critic justified direct typed facts instead of carrier masks/order shadows; more adapter code is accepted for fewer persisted invariants. This is an active implementation checkpoint, not R3 acceptance. Root owns concurrent R6 native work. +## R3 core/source frozen checkpoint (2026-09-06) + +The isolated semantic-review tree now contains the complete 84-scalar/13-family +catalog, keyed member/order/annotation/binding/child/incident/artifact facts, +bounded linked-definition drift, typed historical reference/remap/admission, and +bidirectional mapped review through one ordinary prepared Command batch. Source +and strict schema1–4→5 compatibility are committed in ab960cb; this core checkpoint +adds the closed review implementation. Desktop/API integration and Windows/Linux +native/platform acceptance remain root-owned work. No native input was used here. + +Every source derives proposals from one immutable initial project. Reset old +selectors before scope derivation, compare the union of selected canonical target +slots (including constructor removals), materialize once, then audit every desired +value before and after command preparation. Same-target constructor reconciliation +preserves target-only members; induced cleanup needs exact explicit review and +cannot override an independently selected Keep/Take. Historical facts may outlive +former record families/deleted UUIDs; new resolutions still require current family +and leaf applicability. DECISIONS records why these are distinct admission rules. + +Independent review's original five findings and four residuals are covered by these +passing cases in crates/oa-core/tests/r3_semantic.rs: + +| Finding | Closure regression | +| --- | --- | +| Repeated unequal constructor Keep hides bound drift | repeated_keep_of_unequal_constructor_pair_cannot_swallow_bound_drift | +| StateKind change fails legacy mapping preflight | changed_state_kind_remains_reviewable_in_both_directions | +| Artifact metadata writes collide through different owner traces | artifact_metadata_is_attached_and_cannot_last_write_through_two_owner_traces | +| Constructor retirement overrides another source's Keep | constructor_retirement_cannot_override_other_sources_kept_bound_in_either_order | +| Partial review falsely rolls up as unchanged | semantic_rollup_does_not_claim_unchanged_when_only_unit_is_reviewed | +| Another source's introduced member escapes constructor removal agreement | constructor_union_conflicts_with_another_sources_new_member_in_either_order | +| Source constructor history causes target-only member loss | same_target_constructor_preserves_local_members_and_reset_precedes_expansion | +| Reset old mapping selector reappears during group expansion | same_target_constructor_preserves_local_members_and_reset_precedes_expansion | +| Artifact Owner cannot explicitly move the attached link | artifact_owner_candidate_moves_link_and_conflicts_with_kept_attachment | + +The additional combined_ordered_membership_commutes_or_requires_agreed_anchors +case verifies both request orders: independent removals preserve the local member; +contradictory inferred insertion anchors reject. Coverage includes atomic rollback, +one undo/redo, stale plans, no-op replanning, explicit cleanup acknowledgement, +partial Keep/Take, qualified rows, mapping resets and historical deleted refs. + +Final bounded follow-up extends those same Owner/reset findings: +target_only_artifact_move_records_owner_and_metadata_in_either_order covers an +unpaired target link, Owner alone and Owner+Name in either order, actual resulting +typed owner history, and rejected future metadata review through the former owner. +retired_source_branch_retains_historical_member_pair_in_both_directions preserves +the established pair after a branch disappears instead of adding a colliding +one-sided projection. The opposite appearance case still needs an explicit reset. +The independent read-only re-review found no remaining concrete defect within the +nine findings and these two subsets, including the immutable proposal/union/audit +structure. That is scoped core review closure, not UI/API/spec release acceptance. + +Final Windows core evidence in D:/CodexBuilds/Capella2-semantic-review: + +```text +cargo test --package oa-core --all-targets --locked --offline --config target/semantic/cargo.toml --target-dir D:/CodexBuilds/Capella2-semantic-target --no-fail-fast +cargo clippy --package oa-core --all-targets --locked --offline --config target/semantic/cargo.toml --target-dir D:/CodexBuilds/Capella2-semantic-target -- -D warnings +``` + +Both exit0: 345 passed, 0 failed, 1 intentionally ignored inheritance scaling +probe across 33 test executables; strict all-target Clippy has no warnings. +Logs: target/semantic/core-final-all-tests.log and core-final-clippy.log. Focused +immutable-proposals-artifact-focus.log contains 28 semantic and 7 source tests; the +source suite includes real prepared writeback and included-file save/reopen. The +catalog coverage unit checks every current model payload field's closed capture +and write scope. These logs use the distinct semantic-target and vendored config. + +Native input remains paused following the user's physical stop. Root must handle +resumed native authorization and integrated acceptance; the broader goal is active. + Native input is paused after physical Escape; no R6 native trial has run. Root exclusively owns native input. Latest saved Windows trial is window1706782, ui-state-navigation-windows,49objects/28links/1 expected missing-owner warning. diff --git a/harness/release-semantic-review-implementation.md b/harness/release-semantic-review-implementation.md index a0cb5fea..232f664c 100644 --- a/harness/release-semantic-review-implementation.md +++ b/harness/release-semantic-review-implementation.md @@ -21,6 +21,13 @@ explicit mapping, target-only preservation, honest unreviewed state, source/JSON parity, and one stale-checked validated transaction. No generic metamodel, graph synchronization service, runtime value tree, or second mutation engine is added. +Compiled core catalog: `semantic/types.rs` contains 84 scalar selectors across 13 +families, nine reference collections, two ordered collections, and the closed +Literal/Annotation/Binding/Child/Incident/Artifact facts. The catalog coverage test +enumerates every current element payload, constructor branch and relation kind; +an unrepresented payload field fails that gate. Serialization paths used by the +private adapter are fixed implementation details, never accepted user selectors. + ## Persisted shape and granularity `TraceBaseline` retains existing `oa`/`sa: TracedProperties` and adds @@ -61,6 +68,15 @@ introduced/removed group atomically, retiring obsolete branch facts. Unchanged kind cannot acknowledge unrelated bound/default drift by a Keep shortcut. An explicit actual target switch may reconsider an earlier deliberate Keep. +Persist only `DataConstructor`, `PortConstructor`, `OccurrenceConstructor`, +`EventConstructor` and `MessageConstructor` discriminator enums (State uses the +existing StateKind). A grouped decision uses a transient typed record, retires +obsolete branch facts and stores each resulting descendant independently. A +constructor payload snapshot must never overlap later independently reviewed +leaves. Repeating Keep on an already acknowledged unequal discriminator pair +cannot acknowledge newly changed bounds/defaults. Constructor retirement writes +explicit NotApplicable target slots for agreement checking against other reviews. + Artifact reviews are attached facts of the owning semantic trace; they do not widen ordinary Trace endpoint compatibility. Cover artifact membership and name/description/owner/relation/URI/revision/fragment, preserving target-only links. @@ -104,6 +120,36 @@ Command preparation. Keep the prose request/API compatible; add sibling typed semantic request/preview/apply helpers. Whole existing typed records may appear as transient candidates or impact snapshots, never as persisted baseline carriers. +The implemented sibling boundary is `SemanticTransitionRequest { transition, +traces, references, reviews, candidates, accepted_impacts }`. +`traces` selects a particular trace by source UUID when one endpoint pair has +several traces; `references` is canonical OA→SA correspondence, including old +deleted IDs. `SemanticReviewRequest { source, resolutions, reset }` and +`FacetResolution { facet, decision }` use KeepTarget/TakeSource/Candidate. +Candidates are full existing typed Element/Relation/Artifact records with identity +and aliases protected; they must equal the result of selected facets exactly. +`plan_semantic_transition`, `prepare_semantic_transition` and +`apply_semantic_transition` reuse the existing mapping planner and Command batch. +Previews expose typed current/drift facts, acknowledged and retired selectors, +exact typed prepared impacts, conflicts, and a stale-check fingerprint. + +Artifact metadata slots are keyed by the actual artifact UUID, independent of +which owning trace reaches them. Presence and owner share the ownership slot; +metadata selection requires the currently attached owner. An explicit Owner +candidate can move that link to another valid owner; its canonical owner slot +must agree with any selected attachment decision. Creating a link +requires a complete candidate and explicit review of every introduced metadata +field. Removing a link retires its metadata and checks those removed slots against +other decisions. Historical artifact keys/owners are visited only by explicit +identity remapping, alongside all other historical UUIDs. + +Attachment authorization uses the immutable initial snapshot; metadata observations +use the identified artifact record. Thus a target-only Owner or Owner+Name candidate +records the actual resulting owner/metadata after a move, independent of facet +order. Presence remains relative to the trace owner. A subsequent metadata decision +through the former owner trace is rejected, while its historical observation remains +available for drift. This avoids replacing an explicit owner value with both-side NA. + Freeze mappings before proposals. Resolve all live and historical reference members through explicit correspondence; no display-name matching. Many sources may affect a target only when resolved deltas agree for each canonical target @@ -111,12 +157,46 @@ slot. A source/key-pair collision or contradictory target delta is a conflict, independent of request order. Mapping changes require explicit invalidation/reset of affected old reviews; never silently replace or accumulate overlapping pairs. +An established paired selector remains the identity when one side's member or +constructor branch disappears. It suppresses the redundant generated one-sided +projection, preserving an NA/remaining-value observation. The reverse case is +different: a newly appearing pair does not suppress old one-sided history and +requires an explicit reset. Apply this rule to the closed keyed selector catalog, +not only enumeration or constructor special cases. + +Every selected source derives desired values from the same immutable initial +project. Apply explicit baseline resets before deriving its current catalog or +constructor scope. Union all selected canonical slots across constructor scopes, +including introduced members and removals, and reject disagreement before applying +any writes. Materialize the agreed result once and compare every selected desired +slot against it, then repeat that audit after ordinary command preparation. +An exact approved cleanup impact still cannot contradict a selected Keep/Take. +This invariant replaces sequential proposal mutation and order-dependent audits. +When the target already has the proposed constructor, preserve target-only members +while reconciling its group; a source constructor change is not whole-list deletion +authority. Independently inferred orders must agree; commuting member removals +filter those orders to surviving members without refreshing order history. + Compare each side against its own reviewed value, retaining intentionally unequal pairs. Keep acknowledges only the selected current source/retained target fact. Take or a typed candidate acknowledges only the selected resulting fact. Untouched changes remain drift. Preview unreviewed differences rather than falsely claiming unchanged semantics. A selected constructor group names every fact it acknowledges. +New resolution admission requires the appropriate current record family and at +least one applicable current side. Persisted historical facts are different: their +typed former family/constructor and deleted UUIDs remain valid history even after +the live endpoints change. Validate their closed types, keys, duplicate slots, +finite values, text and work limits without requiring present-day live references +or present-day family applicability. Historical observations are never live edges. + +Linked shared-definition drift is derived, not copied into each referring baseline. +The bounded walk follows reusable data fields/types, exchange items, interfaces, +events and interface allocation declarations, stopping at 4096 objects per side +or 32768 edges with an observable diagnostic. Parent edges and arbitrary architecture +neighborhoods do not expand that walk. Missing pairs/reviews remain Unreviewed; +own and linked rollups explicitly retain their unreviewed counts. + Preserve target-only members and records. Removing source membership proposes unlinking, not deletion of target semantic objects. Ordered insertion is automatic only with a unique unchanged mapped anchor; ambiguous insertion/reorder requires