From 0d14438b7b87608cbf220e6662d24dc768d13127 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sat, 5 Sep 2026 18:22:00 +0300 Subject: [PATCH] Model typed SA ports and atomic exchange allocation workflows --- crates/oa-core/src/demo.rs | 50 +- crates/oa-core/src/lib.rs | 2 + crates/oa-core/src/model.rs | 117 +- crates/oa-core/src/ports.rs | 1199 +++++++++++++++++++++ crates/oa-core/src/projection.rs | 85 +- crates/oa-core/src/references.rs | 88 +- crates/oa-core/src/session.rs | 18 + crates/oa-core/src/transitions.rs | 153 ++- crates/oa-core/src/validation.rs | 192 +--- crates/oa-core/tests/release_ports.rs | 601 +++++++++++ crates/oa-core/tests/release_scenarios.rs | 44 +- crates/oa-core/tests/workflows.rs | 63 +- harness/release-port-core.md | 46 + 13 files changed, 2402 insertions(+), 256 deletions(-) create mode 100644 crates/oa-core/src/ports.rs create mode 100644 crates/oa-core/tests/release_ports.rs create mode 100644 harness/release-port-core.md diff --git a/crates/oa-core/src/demo.rs b/crates/oa-core/src/demo.rs index 9db2bfb3..911bcf92 100644 --- a/crates/oa-core/src/demo.rs +++ b/crates/oa-core/src/demo.rs @@ -34,11 +34,33 @@ fn occurrence( id } fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id { - let id = add(p, Kind::Port, Layer::Sa, name, Some(owner)); - *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port { - direction, - interface: Some(interface), + let items = match p.elements[&interface].shared() { + Some(SharedBody::Interface { items }) => items.clone(), + _ => vec![], }; + let contract = if p.elements[&owner].kind() == Kind::Function { + if direction == PortDirection::Out { + PortContract::FunctionOutput { + outgoing_items: items, + } + } else { + PortContract::FunctionInput { + incoming_items: items, + } + } + } else { + PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: match direction { + PortDirection::In => ComponentPortOrientation::In, + PortDirection::Out => ComponentPortOrientation::Out, + PortDirection::InOut => ComponentPortOrientation::InOut, + }, + } + }; + let id = Id::new_v4(); + let element = crate::ports::make_port(p, id, owner, name, contract).unwrap(); + p.elements.insert(id, element); id } fn diagram( @@ -369,8 +391,8 @@ pub fn coastal_rescue() -> Project { p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange { source: transmit, target: coordinate, - source_port: Some(function_out), - target_port: Some(function_in), + source_port: function_out, + target_port: function_in, items: vec![information], }; let boundary = p.relate( @@ -384,6 +406,18 @@ pub fn coastal_rescue() -> Project { { items.push(information); } + p.relate( + RelationKind::PortAllocation, + "Vessel output represents report output", + vessel_port, + function_out, + ); + p.relate( + RelationKind::PortAllocation, + "System input represents assessment input", + system_port, + function_in, + ); p.relate( RelationKind::ExchangeAllocation, "Report carried by connection", @@ -393,13 +427,13 @@ pub fn coastal_rescue() -> Project { p.relate( RelationKind::InterfaceProvision, "Reporting service provided", - actor, + vessel_port, interface, ); p.relate( RelationKind::InterfaceRequirement, "Reporting service required", - system, + system_port, interface, ); let sa_occurrence = occurrence( diff --git a/crates/oa-core/src/lib.rs b/crates/oa-core/src/lib.rs index d17a386b..a03f5546 100644 --- a/crates/oa-core/src/lib.rs +++ b/crates/oa-core/src/lib.rs @@ -2,12 +2,14 @@ pub mod language; pub mod model; pub mod persistence; +pub mod ports; pub mod projection; pub mod references; pub mod session; pub mod transitions; pub mod validation; pub use model::*; +pub use ports::*; 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 21db8d76..4ab6a196 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 = 2; +pub const SCHEMA_VERSION: u32 = 3; pub const MAX_COORDINATE: f32 = 1_000_000.; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] @@ -293,6 +293,54 @@ pub enum PortDirection { InOut, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ComponentPortKind { + Standard, + Flow, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ComponentPortOrientation { + Unset, + In, + Out, + InOut, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LegacyPortContract { + pub direction: PortDirection, + pub interface: Option, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum PortContract { + FunctionInput { + incoming_items: Vec, + }, + FunctionOutput { + outgoing_items: Vec, + }, + Component { + kind: ComponentPortKind, + orientation: ComponentPortOrientation, + }, + Legacy, +} +impl PortContract { + pub fn items(&self) -> &[Id] { + match self { + Self::FunctionInput { incoming_items } => incoming_items, + Self::FunctionOutput { outgoing_items } => outgoing_items, + _ => &[], + } + } + pub fn is_function(&self) -> bool { + matches!( + self, + Self::FunctionInput { .. } | Self::FunctionOutput { .. } + ) + } +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum StateKind { State, Mode, @@ -468,8 +516,8 @@ pub enum SharedBody { items: Vec, }, Port { - direction: PortDirection, - interface: Option, + contract: PortContract, + legacy: Option, }, Scenario { #[serde(default)] @@ -635,10 +683,8 @@ impl ElementBody { unit: String::new(), }, Kind::Interface => SharedBody::Interface { items: vec![] }, - Kind::Port => SharedBody::Port { - direction: PortDirection::InOut, - interface: None, - }, + // Port creation requires an explicit contract and owner. + Kind::Port => return None, Kind::Scenario => SharedBody::Scenario { viewpoint: if layer == Layer::Oa { ScenarioViewpoint::OperationalActivity @@ -707,6 +753,7 @@ impl SharedBody { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RelationKind { Allocation, + PortAllocation, Interaction, CapabilityInvolvement, ChainInvolvement, @@ -728,8 +775,9 @@ pub enum RelationKind { StateAction, } impl RelationKind { - pub const ALL: [Self; 20] = [ + pub const ALL: [Self; 21] = [ Self::Allocation, + Self::PortAllocation, Self::Interaction, Self::CapabilityInvolvement, Self::ChainInvolvement, @@ -753,6 +801,7 @@ impl RelationKind { pub fn permits(self, from: Kind, to: Kind) -> bool { match self { Self::Allocation => from.is_performer() && to.is_work(), + Self::PortAllocation => from == Kind::Port && to == Kind::Port, Self::Interaction => from == Kind::Activity && to == Kind::Activity, Self::CapabilityInvolvement => { (from.is_performer() || from.is_work() || from == Kind::Role) @@ -807,6 +856,7 @@ impl RelationKind { pub fn hint(self) -> &'static str { match self { Self::Allocation => "Performer → leaf activity/function", + Self::PortAllocation => "Component port → allocated function port", Self::Interaction => "OA activity → OA activity", Self::CapabilityInvolvement => "Participant/activity/function → capability", Self::ChainInvolvement => "Process/chain → capability", @@ -837,6 +887,7 @@ impl fmt::Display for RelationKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(match self { Self::Allocation => "Allocation", + Self::PortAllocation => "Port allocation", Self::Interaction => "Interaction", Self::CapabilityInvolvement => "Capability involvement", Self::ChainInvolvement => "Chain involvement", @@ -875,6 +926,10 @@ pub enum RelationBody { source: Id, target: Id, }, + PortAllocation { + source: Id, + target: Id, + }, Interaction { source: Id, target: Id, @@ -899,13 +954,14 @@ pub enum RelationBody { FunctionalExchange { source: Id, target: Id, - source_port: Option, - target_port: Option, + source_port: Id, + target_port: Id, items: Vec, }, BoundaryExchange { source: Id, target: Id, + oriented: bool, items: Vec, }, MissionCapability { @@ -984,6 +1040,7 @@ impl RelationBody { pub fn kind(&self) -> RelationKind { match self { Self::Allocation { .. } => RelationKind::Allocation, + Self::PortAllocation { .. } => RelationKind::PortAllocation, Self::Interaction { .. } => RelationKind::Interaction, Self::CapabilityInvolvement { .. } => RelationKind::CapabilityInvolvement, Self::ChainInvolvement { .. } => RelationKind::ChainInvolvement, @@ -1008,6 +1065,7 @@ impl RelationBody { pub fn endpoints(&self) -> (Id, Id) { match self { Self::Allocation { source, target } + | Self::PortAllocation { source, target } | Self::Interaction { source, target, .. } | Self::CapabilityInvolvement { source, target } | Self::ChainInvolvement { source, target } @@ -1032,6 +1090,7 @@ impl RelationBody { pub fn set_endpoints(&mut self, a: Id, b: Id) { match self { Self::Allocation { source, target } + | Self::PortAllocation { source, target } | Self::Interaction { source, target, .. } | Self::CapabilityInvolvement { source, target } | Self::ChainInvolvement { source, target } @@ -1067,6 +1126,7 @@ impl RelationBody { pub fn new(kind: RelationKind, source: Id, target: Id) -> Self { match kind { RelationKind::Allocation => Self::Allocation { source, target }, + RelationKind::PortAllocation => Self::PortAllocation { source, target }, RelationKind::Interaction => Self::Interaction { source, target, @@ -1079,13 +1139,15 @@ impl RelationBody { RelationKind::FunctionalExchange => Self::FunctionalExchange { source, target, - source_port: None, - target_port: None, + // Unbound drafts must be completed before candidate admission. + source_port: Id::nil(), + target_port: Id::nil(), items: vec![], }, RelationKind::BoundaryExchange => Self::BoundaryExchange { source, target, + oriented: true, items: vec![], }, RelationKind::MissionCapability => Self::MissionCapability { source, target }, @@ -1584,6 +1646,37 @@ impl Project { pub fn relate(&mut self, kind: RelationKind, name: &str, source: Id, target: Id) -> Id { let id = Id::new_v4(); let alias = self.fresh_alias(name, "relation", id); + if kind == RelationKind::FunctionalExchange + && self.elements.contains_key(&source) + && self.elements.contains_key(&target) + { + let request = crate::ports::FunctionalExchangeRequest { + id, + alias, + name: name.into(), + description: String::new(), + source, + target, + source_port: crate::ports::PortChoice::Auto, + target_port: crate::ports::PortChoice::Auto, + items: vec![], + }; + // Project is the unchecked builder; Session validates the complete batch. + for command in crate::ports::functional_exchange_commands(self, &request) + .expect("fresh exchange identities") + { + match command { + crate::Command::CreateElement(e) => { + self.elements.insert(e.id, e); + } + crate::Command::CreateRelation(r) => { + self.relations.insert(r.id, r); + } + _ => unreachable!("functional creation only creates ports and an exchange"), + } + } + return id; + } let mut body = RelationBody::new(kind, source, target); if self .elements diff --git a/crates/oa-core/src/ports.rs b/crates/oa-core/src/ports.rs new file mode 100644 index 00000000..463c9af4 --- /dev/null +++ b/crates/oa-core/src/ports.rs @@ -0,0 +1,1199 @@ +//! Typed port contracts and concrete, atomic authoring plans. +use crate::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use validation::{Issue, error, warning}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PortChoice { + Auto, + Reuse(Id), + Create, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FunctionalExchangeRequest { + pub id: Id, + pub alias: String, + pub name: String, + pub description: String, + pub source: Id, + pub target: Id, + pub source_port: PortChoice, + pub target_port: PortChoice, + pub items: Vec, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum ComponentExchangeChoice { + Create { + id: Id, + alias: String, + name: String, + description: String, + port_kind: ComponentPortKind, + oriented: bool, + source_port: PortChoice, + target_port: PortChoice, + }, + Reuse(Id), +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentExchangeRequest { + pub functional_exchange: Id, + pub boundary: ComponentExchangeChoice, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LegacyPortResolution { + pub port: Id, + pub contract: PortContract, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FunctionPortSide { + Input, + Output, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LegacyWorkPortSplitRequest { + pub port: Id, + pub primary: FunctionPortSide, + pub incoming_items: Vec, + pub outgoing_items: Vec, +} + +/// R6 specialization has one explicit seam; current models support identity only. +pub fn item_satisfies(_project: &Project, carried: Id, declared: Id) -> bool { + carried == declared +} +pub fn items_satisfy(project: &Project, carried: &[Id], declared: &[Id]) -> bool { + carried.iter().all(|item| { + declared + .iter() + .any(|allowed| item_satisfies(project, *item, *allowed)) + }) +} +pub fn port_contract(e: &Element) -> Option<&PortContract> { + match e.shared()? { + SharedBody::Port { contract, .. } => Some(contract), + _ => None, + } +} +pub fn actual_performer(p: &Project, function: Id) -> Option { + let mut owners = p + .relations + .values() + .filter(|r| r.kind() == RelationKind::Allocation && r.target() == function) + .map(Relation::source); + let owner = owners.next()?; + owners.next().is_none().then_some(owner) +} +pub fn component_allows(contract: &PortContract, outgoing: bool) -> bool { + match contract { + PortContract::Component { + kind: ComponentPortKind::Standard, + .. + } => true, + PortContract::Component { orientation, .. } => match orientation { + ComponentPortOrientation::Unset | ComponentPortOrientation::InOut => true, + ComponentPortOrientation::In => !outgoing, + ComponentPortOrientation::Out => outgoing, + }, + _ => false, + } +} +pub fn has_port_allocation(p: &Project, component: Id, function: Id) -> bool { + p.relations.values().any(|r| { + r.kind() == RelationKind::PortAllocation + && r.source() == component + && r.target() == function + }) +} +/// Explicit role declarations only: participant interfaces are not inherited. +fn declared_interface_items(p: &Project, port: Id) -> Option> { + let interfaces: Vec<_> = p + .relations + .values() + .filter(|r| { + matches!( + r.kind(), + RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement + ) && r.source() == port + }) + .map(Relation::target) + .collect(); + if interfaces.is_empty() { + return None; + } + Some( + interfaces + .into_iter() + .filter_map(|id| p.elements.get(&id)) + .filter_map(|e| match e.shared() { + Some(SharedBody::Interface { items }) => Some(items), + _ => None, + }) + .flatten() + .copied() + .collect(), + ) +} +fn compatible(p: &Project, existing: &Element, desired: &Element, items: &[Id]) -> bool { + if existing.parent != desired.parent || existing.layer() != Layer::Sa { + return false; + } + match (port_contract(existing), port_contract(desired)) { + ( + Some(PortContract::FunctionInput { incoming_items }), + Some(PortContract::FunctionInput { .. }), + ) => items_satisfy(p, items, incoming_items), + ( + Some(PortContract::FunctionOutput { outgoing_items }), + Some(PortContract::FunctionOutput { .. }), + ) => items_satisfy(p, items, outgoing_items), + ( + Some(actual @ PortContract::Component { kind, .. }), + Some(PortContract::Component { + kind: expected, + orientation, + }), + ) => { + kind == expected + && component_allows(actual, *orientation != ComponentPortOrientation::In) + && declared_interface_items(p, existing.id) + .is_none_or(|allowed| items_satisfy(p, items, &allowed)) + } + _ => false, + } +} +fn vacant(p: &Project, id: Id) -> Result<(), ModelError> { + if p.contains(id) { + Err(ModelError(format!( + "port_id_collision: Generated/requested identity {id} already exists." + ))) + } else { + Ok(()) + } +} +pub fn make_port( + p: &Project, + id: Id, + owner: Id, + name: &str, + contract: PortContract, +) -> Result { + let layer = p + .elements + .get(&owner) + .ok_or_else(|| ModelError("port_owner: Choose an existing owner.".into()))? + .layer(); + Ok(Element { + id, + alias: p.fresh_alias(name, "port", id), + name: name.into(), + description: String::new(), + parent: Some(owner), + body: ElementBody::shared_in( + layer, + SharedBody::Port { + contract, + legacy: None, + }, + ), + }) +} +fn select_port( + p: &mut Project, + choice: PortChoice, + desired: Element, + items: &[Id], + commands: &mut Vec, +) -> Result { + let candidates: Vec<_> = p + .elements + .values() + .filter(|e| compatible(p, e, &desired, items)) + .map(|e| e.id) + .collect(); + match choice { + PortChoice::Reuse(id) if candidates.contains(&id) => return Ok(id), + PortChoice::Reuse(_) => { + return Err(ModelError( + "port_reuse: Selected port has an incompatible owner, direction, or item contract." + .into(), + )); + } + PortChoice::Auto if candidates.len() == 1 => return Ok(candidates[0]), + _ => {} + } + vacant(p, desired.id)?; + let id = desired.id; + p.elements.insert(id, desired.clone()); + commands.push(Command::CreateElement(desired)); + Ok(id) +} +fn finish(p: &Project, commands: Vec) -> Result, ModelError> { + execute_batch(p, &commands)?; + Ok(commands) +} +pub fn plan_functional_exchange( + p: &Project, + request: &FunctionalExchangeRequest, +) -> Result, ModelError> { + finish(p, functional_exchange_commands(p, request)?) +} +/// Used inside an existing transaction; its final candidate owns validation. +pub(crate) fn functional_exchange_commands( + p: &Project, + request: &FunctionalExchangeRequest, +) -> Result, ModelError> { + vacant(p, request.id)?; + let mut working = p.clone(); + let mut commands = Vec::new(); + let label = if request.name.trim().is_empty() { + "Exchange" + } else { + &request.name + }; + let output = make_port( + &working, + Id::new_v5(&request.id, b"function-source-port"), + request.source, + &format!("{label} output"), + PortContract::FunctionOutput { + outgoing_items: request.items.clone(), + }, + )?; + let source_port = select_port( + &mut working, + request.source_port, + output, + &request.items, + &mut commands, + )?; + let input = make_port( + &working, + Id::new_v5(&request.id, b"function-target-port"), + request.target, + &format!("{label} input"), + PortContract::FunctionInput { + incoming_items: request.items.clone(), + }, + )?; + let target_port = select_port( + &mut working, + request.target_port, + input, + &request.items, + &mut commands, + )?; + commands.push(Command::CreateRelation(Relation { + id: request.id, + alias: request.alias.clone(), + name: request.name.clone(), + description: request.description.clone(), + body: RelationBody::FunctionalExchange { + source: request.source, + target: request.target, + source_port, + target_port, + items: request.items.clone(), + }, + })); + Ok(commands) +} +fn add_allocation( + p: &mut Project, + kind: RelationKind, + source: Id, + target: Id, + commands: &mut Vec, +) -> Result<(), ModelError> { + if p.relations + .values() + .any(|r| r.kind() == kind && r.source() == source && r.target() == target) + { + return Ok(()); + } + let prefix = if kind == RelationKind::PortAllocation { + "port-allocation" + } else { + "exchange-allocation" + }; + let id = Id::new_v5(&p.id, format!("{prefix}:{source}:{target}").as_bytes()); + vacant(p, id)?; + let name = format!("{} / {}", p.named(source), p.named(target)); + let relation = Relation { + id, + alias: p.fresh_alias(&name, prefix, id), + name, + description: String::new(), + body: RelationBody::new(kind, source, target), + }; + p.relations.insert(id, relation.clone()); + commands.push(Command::CreateRelation(relation)); + Ok(()) +} +pub fn plan_component_exchange( + p: &Project, + request: &ComponentExchangeRequest, +) -> Result, ModelError> { + let fe = p + .relations + .get(&request.functional_exchange) + .ok_or_else(|| { + ModelError("functional_exchange: Select an existing functional exchange.".into()) + })?; + let RelationBody::FunctionalExchange { + source, + target, + source_port: output, + target_port: input, + items, + } = &fe.body + else { + return Err(ModelError( + "functional_exchange: Select a functional exchange.".into(), + )); + }; + if !p + .elements + .get(output) + .is_some_and(|e| matches!(port_contract(e), Some(PortContract::FunctionOutput { .. }))) + || !p + .elements + .get(input) + .is_some_and(|e| matches!(port_contract(e), Some(PortContract::FunctionInput { .. }))) + { + return Err(ModelError( + "legacy_port: Resolve functional endpoints before component exchange generation." + .into(), + )); + } + let (Some(from), Some(to)) = (actual_performer(p, *source), actual_performer(p, *target)) + else { + return Err(ModelError( + "port_allocation_owner: Allocate both functions to actual participants first.".into(), + )); + }; + let mut working = p.clone(); + let mut commands = Vec::new(); + let (boundary, from_port, to_port) = match &request.boundary { + ComponentExchangeChoice::Create { + id, + alias, + name, + description, + port_kind, + oriented, + source_port, + target_port, + } => { + vacant(&working, *id)?; + let contract = |outgoing| PortContract::Component { + kind: *port_kind, + orientation: if *port_kind == ComponentPortKind::Standard { + ComponentPortOrientation::Unset + } else if outgoing { + ComponentPortOrientation::Out + } else { + ComponentPortOrientation::In + }, + }; + let output_port = make_port( + &working, + Id::new_v5(id, b"component-source-port"), + from, + &format!("{name} source port"), + contract(true), + )?; + let a = select_port( + &mut working, + *source_port, + output_port, + items, + &mut commands, + )?; + let input_port = make_port( + &working, + Id::new_v5(id, b"component-target-port"), + to, + &format!("{name} target port"), + contract(false), + )?; + let b = select_port(&mut working, *target_port, input_port, items, &mut commands)?; + let relation = Relation { + id: *id, + alias: alias.clone(), + name: name.clone(), + description: description.clone(), + body: RelationBody::BoundaryExchange { + source: a, + target: b, + oriented: *oriented, + items: items.clone(), + }, + }; + working.relations.insert(*id, relation.clone()); + commands.push(Command::CreateRelation(relation)); + (*id, a, b) + } + ComponentExchangeChoice::Reuse(id) => { + let mut boundary = working.relations.get(id).cloned().ok_or_else(|| { + ModelError("boundary_exchange: Select an existing boundary exchange.".into()) + })?; + let RelationBody::BoundaryExchange { + source: a, + target: b, + oriented, + items: carried, + } = &mut boundary.body + else { + return Err(ModelError( + "boundary_exchange: Select a boundary exchange.".into(), + )); + }; + let owner = |port| p.elements.get(&port).and_then(|e| e.parent); + let endpoints = if owner(*a) == Some(from) && owner(*b) == Some(to) { + (*a, *b) + } else if !*oriented && owner(*b) == Some(from) && owner(*a) == Some(to) { + (*b, *a) + } else { + return Err(ModelError("boundary_owner: Boundary participants do not perform the selected functions in a compatible direction.".into())); + }; + for item in items { + if !carried.contains(item) { + carried.push(*item); + } + } + if working.relations[id] != boundary { + working.relations.insert(*id, boundary.clone()); + commands.push(Command::EditRelation(*id, boundary)); + } + (*id, endpoints.0, endpoints.1) + } + }; + add_allocation( + &mut working, + RelationKind::PortAllocation, + from_port, + *output, + &mut commands, + )?; + add_allocation( + &mut working, + RelationKind::PortAllocation, + to_port, + *input, + &mut commands, + )?; + add_allocation( + &mut working, + RelationKind::ExchangeAllocation, + boundary, + fe.id, + &mut commands, + )?; + finish(p, commands) +} +pub fn plan_legacy_port_resolution( + p: &Project, + request: &LegacyPortResolution, +) -> Result, ModelError> { + let mut element = p + .elements + .get(&request.port) + .cloned() + .ok_or_else(|| ModelError("legacy_port: Select an existing legacy port.".into()))?; + let Some(SharedBody::Port { + contract, + legacy: Some(_), + }) = element.shared_mut() + else { + return Err(ModelError( + "legacy_port: Select a preserved legacy port.".into(), + )); + }; + if *contract != PortContract::Legacy || request.contract == PortContract::Legacy { + return Err(ModelError( + "legacy_port: Resolution requires an unresolved port and a typed contract.".into(), + )); + } + *contract = request.contract.clone(); + finish(p, vec![Command::EditElement(element.id, element)]) +} +pub fn plan_legacy_work_port_split( + p: &Project, + request: &LegacyWorkPortSplitRequest, +) -> Result, ModelError> { + let mut primary = p + .elements + .get(&request.port) + .cloned() + .ok_or_else(|| ModelError("legacy_port: Select an existing legacy port.".into()))?; + if primary.layer() != Layer::Sa + || !primary + .parent + .and_then(|id| p.elements.get(&id)) + .is_some_and(|e| e.kind() == Kind::Function) + || !matches!( + primary.shared(), + Some(SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::InOut, + .. + }) + }) + ) + { + return Err(ModelError( + "legacy_port_split: Select an unresolved SA function InOut port.".into(), + )); + } + let other_id = Id::new_v5(&primary.id, b"split-opposite-port"); + vacant(p, other_id)?; + let input_contract = PortContract::FunctionInput { + incoming_items: request.incoming_items.clone(), + }; + let output_contract = PortContract::FunctionOutput { + outgoing_items: request.outgoing_items.clone(), + }; + let (contract, opposite, suffix, input, output) = if request.primary == FunctionPortSide::Input + { + ( + input_contract, + output_contract, + "output", + primary.id, + other_id, + ) + } else { + ( + output_contract, + input_contract, + "input", + other_id, + primary.id, + ) + }; + if let Some(SharedBody::Port { + contract: current, .. + }) = primary.shared_mut() + { + *current = contract; + } + let sibling = make_port( + p, + other_id, + primary.parent.unwrap(), + &format!("{} {suffix}", primary.name), + opposite, + )?; + let mut commands = vec![ + Command::EditElement(primary.id, primary), + Command::CreateElement(sibling), + ]; + for r in p.relations.values() { + let mut changed = r.clone(); + if let RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } = &mut changed.body + { + if *source_port == request.port { + *source_port = output; + } + if *target_port == request.port { + *target_port = input; + } + } + if changed != *r { + commands.push(Command::EditRelation(r.id, changed)); + } + } + finish(p, commands) +} + +/// Explicit synchronization preserves port identity; final validation protects all users. +pub fn plan_function_port_items( + p: &Project, + port: Id, + items: Vec, +) -> Result, ModelError> { + let mut e = + p.elements.get(&port).cloned().ok_or_else(|| { + ModelError("functional_port: Select an existing function port.".into()) + })?; + match e.shared_mut() { + Some(SharedBody::Port { + contract: PortContract::FunctionInput { incoming_items }, + .. + }) => *incoming_items = items, + Some(SharedBody::Port { + contract: PortContract::FunctionOutput { outgoing_items }, + .. + }) => *outgoing_items = items, + _ => { + return Err(ModelError( + "functional_port: Select a typed function port.".into(), + )); + } + } + finish(p, vec![Command::EditElement(port, e)]) +} + +pub(crate) fn validate_port(p: &Project, e: &Element, out: &mut Vec) { + let Some(SharedBody::Port { contract, legacy }) = e.shared() else { + return; + }; + let owner = e.parent.and_then(|id| p.elements.get(&id)); + if let Some(id) = legacy.as_ref().and_then(|old| old.interface) + && !p.elements.get(&id).is_some_and(|interface| { + interface.kind() == Kind::Interface && interface.layer() == e.layer() + }) + { + error( + out, + e.id, + "port_legacy_interface", + "interface", + "Preserved interface must exist in the port's layer.", + ); + } + if *contract == PortContract::Legacy { + if legacy.is_none() { + error( + out, + e.id, + "port_legacy_missing", + "legacy", + "An unresolved port requires its preserved generic contract.", + ); + } + warning( + out, + e.id, + "legacy_port", + "contract", + "Review this preserved generic port before assigning a typed SA contract.", + ); + return; + } + let valid_owner = e.layer() == Layer::Sa + && owner.is_some_and(|owner| { + owner.layer() == Layer::Sa + && if contract.is_function() { + owner.kind() == Kind::Function + } else { + matches!(owner.kind(), Kind::System | Kind::SystemActor) + } + }); + if !valid_owner { + error( + out, + e.id, + "port_owner", + "parent", + "Typed ports belong to an SA Function or an SA participant according to their contract; OA has no typed ports.", + ); + } + match contract { + PortContract::FunctionInput { incoming_items } + | PortContract::FunctionOutput { + outgoing_items: incoming_items, + } => { + let field = if matches!(contract, PortContract::FunctionInput { .. }) { + "incoming_items" + } else { + "outgoing_items" + }; + let mut seen = BTreeSet::new(); + for id in incoming_items { + if !seen.insert(*id) { + error( + out, + e.id, + "duplicate_reference", + field, + "Port item declarations contain unique identities.", + ); + } + if !p.elements.get(id).is_some_and(|item| { + item.kind() == Kind::ExchangeItem && item.layer() == e.layer() + }) { + error( + out, + e.id, + "port_item", + field, + "Port items must be exchange items in the same layer.", + ); + } + } + if incoming_items.is_empty() { + warning( + out, + e.id, + "empty_port_contract", + field, + "This function port has no declared exchange items.", + ); + } + } + PortContract::Component { + kind: ComponentPortKind::Standard, + orientation, + } if *orientation != ComponentPortOrientation::Unset => error( + out, + e.id, + "standard_port_orientation", + "orientation", + "Standard ports have no Flow orientation; use Unset.", + ), + PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: ComponentPortOrientation::Unset, + } => warning( + out, + e.id, + "unset_port_orientation", + "orientation", + "Choose the Flow port's orientation after reviewing its communication direction.", + ), + _ => {} + } +} +fn legacy_allows(e: &Element, outgoing: bool) -> bool { + matches!(e.shared(), Some(SharedBody::Port { contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction, .. }) }) if if outgoing { *direction != PortDirection::In } else { *direction != PortDirection::Out }) +} +fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mut Vec) { + match port.shared() { + Some(SharedBody::Port { + contract: PortContract::FunctionInput { incoming_items }, + .. + }) => { + if !items_satisfy(p, r.body.items(), incoming_items) { + error( + out, + r.id, + "function_port_items", + "items", + "An exchange item is outside its input port's incoming declaration.", + ); + } + } + Some(SharedBody::Port { + contract: PortContract::FunctionOutput { outgoing_items }, + .. + }) => { + if !items_satisfy(p, r.body.items(), outgoing_items) { + error( + out, + r.id, + "function_port_items", + "items", + "An exchange item is outside its output port's outgoing declaration.", + ); + } + } + Some(SharedBody::Port { + contract: PortContract::Component { .. }, + .. + }) => { + if let Some(items) = declared_interface_items(p, port.id) + && !items_satisfy(p, r.body.items(), &items) + { + error( + out, + r.id, + "port_interface_items", + "items", + "The component exchange carries an item outside this port's declared interfaces.", + ); + } + } + Some(SharedBody::Port { + contract: PortContract::Legacy, + legacy, + .. + }) => { + let mut allowed = declared_interface_items(p, port.id); + if let Some(SharedBody::Interface { items }) = legacy + .as_ref() + .and_then(|l| l.interface) + .and_then(|id| p.elements.get(&id)) + .and_then(Element::shared) + { + if r.kind() == RelationKind::BoundaryExchange + && !items_satisfy(p, r.body.items(), items) + { + error( + out, + r.id, + "interface_contract", + "items", + "Exchange items must fit the preserved legacy interface association.", + ); + } + allowed.get_or_insert_with(Vec::new).extend(items); + } + if let Some(items) = allowed + && !items_satisfy(p, r.body.items(), &items) + { + error( + out, + r.id, + "port_interface_items", + "items", + "The exchange carries an item outside its legacy interface declarations.", + ); + } + } + _ => {} + } +} +pub(crate) fn validate_port_relation(p: &Project, r: &Relation, out: &mut Vec) { + match &r.body { + RelationBody::FunctionalExchange { + source, + target, + source_port, + target_port, + .. + } => { + for (id, owner, outgoing, field) in [ + (*source_port, *source, true, "source_port"), + (*target_port, *target, false, "target_port"), + ] { + let Some(port) = p.elements.get(&id) else { + error( + out, + r.id, + "functional_port", + field, + "A functional exchange requires an existing port at each endpoint.", + ); + continue; + }; + let typed = matches!( + (port_contract(port), outgoing), + (Some(PortContract::FunctionOutput { .. }), true) + | (Some(PortContract::FunctionInput { .. }), false) + ); + if port.parent != Some(owner) + || port.layer() != Layer::Sa + || !(typed || legacy_allows(port, outgoing)) + { + error( + out, + r.id, + "functional_port", + field, + "Functional endpoints must have the matching function owner and output/input contract.", + ); + } + validate_carried_contract(p, r, port, out); + } + } + RelationBody::BoundaryExchange { + source, + target, + oriented, + .. + } => { + let (Some(a), Some(b)) = (p.elements.get(source), p.elements.get(target)) else { + return; + }; + for (port, outgoing, field) in [(a, true, "source"), (b, false, "target")] { + let participant = port + .parent + .and_then(|id| p.elements.get(&id)) + .is_some_and(|owner| owner.kind().is_performer()); + let allowed = match port_contract(port) { + Some(contract @ PortContract::Component { .. }) => { + port.layer() == Layer::Sa + && (!*oriented || component_allows(contract, outgoing)) + } + Some(PortContract::Legacy) => legacy_allows(port, outgoing), + _ => false, + }; + if !participant || !allowed { + error( + out, + r.id, + "boundary_port", + field, + "Boundary endpoints require compatible component ports owned by participants (or preserved legacy participant ports).", + ); + } + validate_carried_contract(p, r, port, out); + } + if !*oriented + && let ( + Some( + ac @ PortContract::Component { + kind: ComponentPortKind::Flow, + .. + }, + ), + Some( + bc @ PortContract::Component { + kind: ComponentPortKind::Flow, + .. + }, + ), + ) = (port_contract(a), port_contract(b)) + && !((component_allows(ac, true) && component_allows(bc, false)) + || (component_allows(ac, false) && component_allows(bc, true))) + { + error( + out, + r.id, + "boundary_orientation", + "oriented", + "These Flow ports have no compatible communication direction.", + ); + } + if let ( + Some(PortContract::Component { kind: ak, .. }), + Some(PortContract::Component { kind: bk, .. }), + ) = (port_contract(a), port_contract(b)) + && ak != bk + { + error( + out, + r.id, + "mixed_component_ports", + "endpoints", + "A component exchange must bind ports of the same kind (both Standard or both Flow).", + ); + } + if [a, b] + .iter() + .any(|e| matches!(port_contract(e), Some(PortContract::Legacy))) + { + warning( + out, + r.id, + "legacy_boundary", + "endpoints", + "This boundary exchange retains unresolved generic port semantics.", + ); + } + for (port, peer) in [(a, b), (b, a)] { + if !matches!( + port_contract(port), + Some(PortContract::Component { + kind: ComponentPortKind::Standard, + .. + }) + ) { + continue; + } + for required in p.relations.values().filter(|role| { + role.kind() == RelationKind::InterfaceRequirement && role.source() == port.id + }) { + if !p.relations.values().any(|role| { + role.kind() == RelationKind::InterfaceProvision + && role.source() == peer.id + && role.target() == required.target() + }) { + warning( + out, + required.id, + "unsatisfied_interface_requirement", + "target", + "The connected partner has not declared this required interface as provided.", + ); + } + } + } + } + RelationBody::PortAllocation { source, target } => { + let (Some(component), Some(function)) = + (p.elements.get(source), p.elements.get(target)) + else { + return; + }; + let outgoing = matches!( + port_contract(function), + Some(PortContract::FunctionOutput { .. }) + ); + if component.layer() != Layer::Sa + || function.layer() != Layer::Sa + || !port_contract(function).is_some_and(PortContract::is_function) + || !port_contract(component).is_some_and(|c| component_allows(c, outgoing)) + { + error( + out, + r.id, + "port_allocation_contract", + "endpoints", + "Port allocation requires an SA component port and a compatible typed function port.", + ); + } + if function.parent.and_then(|id| actual_performer(p, id)) != component.parent + || component.parent.is_none() + { + error( + out, + r.id, + "port_allocation_owner", + "endpoints", + "The function must have an actual work allocation to the allocating port's component.", + ); + } + if p.relations.values().any(|other| { + other.id != r.id + && other.kind() == RelationKind::PortAllocation + && other.source() == *source + && other.target() == *target + }) { + error( + out, + r.id, + "duplicate_port_allocation", + "endpoints", + "There is already a port allocation for these endpoints.", + ); + } + } + RelationBody::InterfaceProvision { source, .. } + | RelationBody::InterfaceRequirement { source, .. } => { + let Some(port) = p.elements.get(source) else { + return; + }; + if let Some(SharedBody::Port { contract, legacy }) = port.shared() { + if *contract == PortContract::Legacy || (contract.is_function() && legacy.is_some()) + { + warning( + out, + r.id, + "legacy_interface_role", + "source", + "This preserved legacy interface role does not replace the typed function item contract.", + ); + } else if contract.is_function() { + error( + out, + r.id, + "function_interface_role", + "source", + "Use function input/output item declarations; interface roles belong to component ports or participants.", + ); + } + } + } + _ => {} + } +} +pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec) { + let (Some(boundary), Some(work)) = (p.relations.get(&r.source()), p.relations.get(&r.target())) + else { + return; + }; + let RelationBody::BoundaryExchange { + source: a, + target: b, + oriented, + .. + } = &boundary.body + else { + return; + }; + let owner = |port| p.elements.get(&port).and_then(|e| e.parent); + let direct = owner(*a) == actual_performer(p, work.source()) + && owner(*b) == actual_performer(p, work.target()); + let reverse = !*oriented + && owner(*b) == actual_performer(p, work.source()) + && owner(*a) == actual_performer(p, work.target()); + let legacy = [*a, *b].iter().any(|id| { + p.elements + .get(id) + .is_some_and(|e| matches!(port_contract(e), Some(PortContract::Legacy))) + }) || match &work.body { + RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } => [*source_port, *target_port].iter().any(|id| { + p.elements + .get(id) + .is_some_and(|e| matches!(port_contract(e), Some(PortContract::Legacy))) + }), + _ => true, + }; + if legacy { + warning( + out, + r.id, + "legacy_exchange_allocation", + "endpoints", + "Resolve legacy ports and explicitly allocate the corresponding typed ports.", + ); + for (function, port) in [(work.source(), *a), (work.target(), *b)] { + match (actual_performer(p, function), owner(port)) { + (Some(actual), Some(owner)) if actual != owner => error( + out, + r.id, + "exchange_allocation_performer", + "endpoints", + "Boundary owner must perform the corresponding work endpoint.", + ), + (None, _) | (_, None) => warning( + out, + r.id, + "incomplete_exchange_allocation", + "endpoints", + "Allocate the corresponding work endpoints to participants.", + ), + _ => {} + } + } + } else { + if !direct && !reverse { + error( + out, + r.id, + "exchange_allocation_performer", + "endpoints", + "Boundary participants must perform the corresponding function endpoints.", + ); + } + if let RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } = &work.body + { + let mapped = (direct + && has_port_allocation(p, *a, *source_port) + && has_port_allocation(p, *b, *target_port)) + || (reverse + && has_port_allocation(p, *b, *source_port) + && has_port_allocation(p, *a, *target_port)); + if !mapped { + error( + out, + r.id, + "exchange_port_allocation", + "endpoints", + "The component exchange must explicitly allocate both corresponding function ports.", + ); + } + } + } + if !items_satisfy(p, work.body.items(), boundary.body.items()) { + error( + out, + r.id, + "exchange_allocation_items", + "items", + "The boundary exchange must carry every allocated work-exchange item.", + ); + } +} diff --git a/crates/oa-core/src/projection.rs b/crates/oa-core/src/projection.rs index b5a2299a..9edc92b7 100644 --- a/crates/oa-core/src/projection.rs +++ b/crates/oa-core/src/projection.rs @@ -18,6 +18,7 @@ pub struct ProjectedEdge { pub source: Id, pub target: Id, pub trace: bool, + pub oriented: bool, pub source_port: Option, pub target_port: Option, } @@ -27,7 +28,36 @@ pub struct ProjectedPort { /// Placement identity of the owning visible function/participant. pub owner: Id, pub position: Position, - pub direction: PortDirection, + pub side: PortAnchorSide, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PortAnchorSide { + Left, + Right, + Bottom, +} +pub fn port_anchor_side(e: &Element) -> Option { + let Some(SharedBody::Port { contract, legacy }) = e.shared() else { + return None; + }; + Some(match contract { + PortContract::FunctionInput { .. } => PortAnchorSide::Left, + PortContract::FunctionOutput { .. } => PortAnchorSide::Right, + PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: ComponentPortOrientation::In, + } => PortAnchorSide::Left, + PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: ComponentPortOrientation::Out, + } => PortAnchorSide::Right, + PortContract::Legacy => match legacy.as_ref().map(|l| l.direction) { + Some(PortDirection::In) => PortAnchorSide::Left, + Some(PortDirection::Out) => PortAnchorSide::Right, + _ => PortAnchorSide::Bottom, + }, + _ => PortAnchorSide::Bottom, + }) } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct ProjectedView { @@ -36,10 +66,25 @@ pub struct ProjectedView { pub ports: Vec, } pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size { - let count = [PortDirection::In, PortDirection::Out].into_iter().map(|direction| p.children(element).filter(|port| matches!(port.shared(), Some(SharedBody::Port {direction:d,..}) if *d==direction)).count()).max().unwrap_or(0); + let count = [PortAnchorSide::Left, PortAnchorSide::Right] + .into_iter() + .map(|side| { + p.children(element) + .filter(|port| port_anchor_side(port) == Some(side)) + .count() + }) + .max() + .unwrap_or(0); if count > 0 { size.height = size.height.max(76. + 28. * (count + 1) as f32); } + let bottom = p + .children(element) + .filter(|port| port_anchor_side(port) == Some(PortAnchorSide::Bottom)) + .count(); + if bottom > 0 { + size.width = size.width.max(28. * (bottom + 1) as f32); + } size } @@ -205,20 +250,27 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView { .filter(|e| e.kind() == Kind::Port) .collect(); children.sort_by_key(|e| (&e.alias, e.id)); - for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] { - let side: Vec<_> = children.iter().filter(|e| matches!(e.shared(), Some(SharedBody::Port {direction: d, ..}) if *d == direction)).collect(); + for anchor_side in [ + PortAnchorSide::Left, + PortAnchorSide::Right, + PortAnchorSide::Bottom, + ] { + let side: Vec<_> = children + .iter() + .filter(|e| port_anchor_side(e) == Some(anchor_side)) + .collect(); for (index, port) in side.iter().enumerate() { let fraction = (index + 1) as f32 / (side.len() + 1) as f32; - let position = match direction { - PortDirection::In => Position { + let position = match anchor_side { + PortAnchorSide::Left => Position { x: node.position.x, y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction, }, - PortDirection::Out => Position { + PortAnchorSide::Right => Position { x: node.position.x + node.size.width, y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction, }, - PortDirection::InOut => Position { + PortAnchorSide::Bottom => Position { x: node.position.x + node.size.width * fraction, y: node.position.y + node.size.height, }, @@ -227,7 +279,7 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView { element: port.id, owner: node.id, position, - direction, + side: anchor_side, }); } } @@ -270,10 +322,9 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView { source_port, target_port, .. - } => (*source_port, *target_port), - RelationBody::BoundaryExchange { source, target, .. } => { - (Some(*source), Some(*target)) - } + } => (Some(*source_port), Some(*target_port)), + RelationBody::BoundaryExchange { source, target, .. } + | RelationBody::PortAllocation { source, target } => (Some(*source), Some(*target)), _ => (None, None), }; edges.push(ProjectedEdge { @@ -281,6 +332,13 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView { source, target, trace: false, + oriented: !matches!( + &r.body, + RelationBody::BoundaryExchange { + oriented: false, + .. + } + ), source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)), target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)), }); @@ -296,6 +354,7 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView { source, target, trace: true, + oriented: true, source_port: None, target_port: None, }); diff --git a/crates/oa-core/src/references.rs b/crates/oa-core/src/references.rs index b03d10bc..16b7f230 100644 --- a/crates/oa-core/src/references.rs +++ b/crates/oa-core/src/references.rs @@ -45,10 +45,20 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) { visit(id); } } - SharedBody::Port { - interface: Some(id), - .. - } => visit(id), + SharedBody::Port { contract, legacy } => { + match contract { + PortContract::FunctionInput { incoming_items } => { + incoming_items.iter_mut().for_each(&mut visit) + } + PortContract::FunctionOutput { outgoing_items } => { + outgoing_items.iter_mut().for_each(&mut visit) + } + _ => {} + } + if let Some(id) = legacy.as_mut().and_then(|old| old.interface.as_mut()) { + visit(id); + } + } SharedBody::Scenario { context, steps, .. } => { if let Some(id) = context { visit(id); @@ -145,7 +155,7 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) { items, .. } => { - for id in [source_port, target_port].into_iter().flatten() { + for id in [source_port, target_port] { visit(id); } for id in items { @@ -207,7 +217,49 @@ pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet { } } for r in p.relations.values() { - if removed.contains(&r.source()) || removed.contains(&r.target()) { + let port_removed = matches!(&r.body, RelationBody::FunctionalExchange { source_port, target_port, .. } if removed.contains(source_port) || removed.contains(target_port)); + let work_allocation_removed = r.kind() == RelationKind::PortAllocation + && p.relations.values().any(|allocation| { + allocation.kind() == RelationKind::Allocation + && removed.contains(&allocation.id) + && p.elements.get(&r.source()).and_then(|e| e.parent) + == Some(allocation.source()) + && p.elements.get(&r.target()).and_then(|e| e.parent) + == Some(allocation.target()) + }); + if removed.contains(&r.source()) + || removed.contains(&r.target()) + || port_removed + || work_allocation_removed + { + removed.insert(r.id); + } + } + // Exchange allocation depends on both explicit endpoint allocations. + for r in p + .relations + .values() + .filter(|r| r.kind() == RelationKind::ExchangeAllocation) + { + let (Some(boundary), Some(work)) = + (p.relations.get(&r.source()), p.relations.get(&r.target())) + else { + continue; + }; + let RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } = &work.body + else { + continue; + }; + if p.relations.values().any(|allocation| { + allocation.kind() == RelationKind::PortAllocation + && removed.contains(&allocation.id) + && [boundary.source(), boundary.target()].contains(&allocation.source()) + && [*source_port, *target_port].contains(&allocation.target()) + }) { removed.insert(r.id); } } @@ -263,7 +315,20 @@ pub fn delete_cascade(p: &mut Project, id: Id) { SharedBody::Event { body: EventBody::Signal { items }, } => items.retain(|id| !removed.contains(id)), - SharedBody::Port { interface, .. } => clear(interface), + SharedBody::Port { contract, legacy } => { + match contract { + PortContract::FunctionInput { incoming_items } => { + incoming_items.retain(|id| !removed.contains(id)) + } + PortContract::FunctionOutput { outgoing_items } => { + outgoing_items.retain(|id| !removed.contains(id)) + } + _ => {} + } + if let Some(old) = legacy { + clear(&mut old.interface); + } + } SharedBody::Scenario { context, .. } => clear(context), SharedBody::Lifeline { represented } => clear(represented), SharedBody::Constraint { constrained, .. } => { @@ -336,15 +401,8 @@ pub fn delete_cascade(p: &mut Project, id: Id) { | RelationBody::BoundaryExchange { items, .. } => { items.retain(|id| !removed.contains(id)) } - RelationBody::FunctionalExchange { - items, - source_port, - target_port, - .. - } => { + RelationBody::FunctionalExchange { items, .. } => { items.retain(|id| !removed.contains(id)); - clear(source_port); - clear(target_port); } _ => {} } diff --git a/crates/oa-core/src/session.rs b/crates/oa-core/src/session.rs index b6666c4a..4f697f0c 100644 --- a/crates/oa-core/src/session.rs +++ b/crates/oa-core/src/session.rs @@ -453,6 +453,24 @@ fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result { + if *kind == RelationKind::FunctionalExchange { + let id = Id::new_v4(); + let commands = crate::ports::functional_exchange_commands( + p, + &FunctionalExchangeRequest { + id, + alias: p.fresh_alias(name, "relation", id), + name: name.clone(), + description: String::new(), + source: *source, + target: *target, + source_port: PortChoice::Auto, + target_port: PortChoice::Auto, + items: vec![], + }, + )?; + return apply_command(p, &Command::Batch(commands), depth + 1); + } Some(p.relate(*kind, name, *source, *target)) } Command::Delete(id) => { diff --git a/crates/oa-core/src/transitions.rs b/crates/oa-core/src/transitions.rs index 32789869..3795071d 100644 --- a/crates/oa-core/src/transitions.rs +++ b/crates/oa-core/src/transitions.rs @@ -196,6 +196,11 @@ fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option { }) } fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option { + if layer == Layer::Oa + && crate::ports::port_contract(e).is_some_and(|contract| *contract != PortContract::Legacy) + { + return None; + } if let Some(shared) = e.shared() && kind == e.kind() { @@ -232,8 +237,8 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option &String { TransitionField::Description => &props.description, } } + +/// SA port implementation details have no fabricated OA counterpart. Propagate +/// omissions through dependent records, except the FE whose ports are removed +/// by its explicit Interaction mapping. +fn sa_port_omissions(p: &Project) -> BTreeSet { + let mut omitted: BTreeSet<_> = p + .elements + .values() + .filter(|e| { + e.layer() == Layer::Sa + && crate::ports::port_contract(e).is_some_and(|c| *c != PortContract::Legacy) + }) + .map(|e| e.id) + .collect(); + loop { + let before = omitted.len(); + for r in p + .relations + .values() + .filter(|r| r.kind() != RelationKind::FunctionalExchange) + { + if crate::references::relation_references(r) + .iter() + .any(|id| omitted.contains(id)) + { + omitted.insert(r.id); + } + } + for e in p.elements.values().filter(|e| e.layer() == Layer::Sa) { + if crate::references::element_references(e) + .iter() + .any(|id| omitted.contains(id)) + { + omitted.insert(e.id); + } + } + if before == omitted.len() { + return omitted; + } + } +} fn set(props: &mut TracedProperties, field: TransitionField, value: String) { match field { TransitionField::Name => props.name = value, @@ -341,6 +387,12 @@ pub fn plan_transition( commands: vec![], }; let mut selected: BTreeSet<_> = request.sources.iter().copied().collect(); + let omitted = if request.direction == TransitionDirection::SaToOa { + sa_port_omissions(project) + } else { + BTreeSet::new() + }; + let mut reported_omissions = BTreeSet::new(); if selected.len() != request.sources.len() { conflict( &mut plan, @@ -364,6 +416,9 @@ pub fn plan_transition( let before = selected.len(); let existing: Vec<_> = selected.iter().copied().collect(); for id in existing { + if omitted.contains(&id) { + continue; + } if let Some(e) = project.elements.get(&id) { selected.extend(crate::references::element_references(e)); // Contextual links are owned by the process/chain shared by @@ -398,7 +453,27 @@ pub fn plan_transition( selected.extend(project.children(id).map(|e| e.id)); } } else if let Some(r) = project.relations.get(&id) { - selected.extend(crate::references::relation_references(r)); + if request.direction == TransitionDirection::SaToOa + && let RelationBody::FunctionalExchange { + source, + target, + source_port, + target_port, + items, + } = &r.body + { + // An Interaction needs the work/items, never either SA + // endpoint port, including an unresolved legacy one. + selected.extend([*source, *target]); + selected.extend(items); + for port in [*source_port, *target_port] { + if reported_omissions.insert(port) { + plan.warnings.push(format!("SA-only omission: functional endpoint {} ({port}) is not copied into the portless OA interaction; the original is preserved.", project.named(port))); + } + } + } else { + selected.extend(crate::references::relation_references(r)); + } } } if selected.len() == before { @@ -406,6 +481,12 @@ pub fn plan_transition( } } } + selected.retain(|id| { + if omitted.contains(id) { + if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); } + false + } else { true } + }); let mut choices = BTreeMap::new(); for mapping in &request.mappings { if choices.insert(mapping.source, mapping).is_some() { @@ -569,7 +650,17 @@ pub fn plan_transition( } } let mut working = project.clone(); - for source in selected.iter().filter(|id| creating.contains(id)) { + let ordered_creations: Vec<_> = selected + .iter() + .filter(|id| creating.contains(id) && project.elements.contains_key(id)) + .chain( + selected + .iter() + .filter(|id| creating.contains(id) && project.relations.contains_key(id)), + ) + .copied() + .collect(); + for source in &ordered_creations { let Some(target) = targets.get(source).copied() else { continue; }; @@ -643,6 +734,12 @@ pub fn plan_transition( } }); for dependency in crate::references::relation_references(&created) { + if dependency.is_nil() + && request.direction == TransitionDirection::OaToSa + && r.kind() == RelationKind::Interaction + { + continue; + } if !targets.values().any(|id| *id == dependency) && working.layer_of(dependency) != Some(request.direction.target_layer()) { @@ -660,8 +757,52 @@ pub fn plan_transition( if !request.fields.contains(&TransitionField::Description) { created.description.clear(); } - working.relations.insert(target, created.clone()); - plan.commands.push(Command::CreateRelation(created)); + if request.direction == TransitionDirection::OaToSa + && let RelationBody::FunctionalExchange { + source, + target: destination, + items, + .. + } = &created.body + { + let request = FunctionalExchangeRequest { + id: created.id, + alias: created.alias.clone(), + name: created.name.clone(), + description: created.description.clone(), + source: *source, + target: *destination, + source_port: PortChoice::Auto, + target_port: PortChoice::Auto, + items: items.clone(), + }; + match crate::ports::functional_exchange_commands(&working, &request) { + Ok(commands) => { + for command in &commands { + match command { + Command::CreateElement(e) => { + working.elements.insert(e.id, e.clone()); + } + Command::CreateRelation(r) => { + working.relations.insert(r.id, r.clone()); + } + _ => unreachable!("functional creation only creates objects"), + } + } + plan.commands.extend(commands); + } + Err(err) => conflict( + &mut plan, + Some(*source), + Some(target), + None, + format!("Functional endpoint creation: {err}"), + ), + } + } else { + working.relations.insert(target, created.clone()); + plan.commands.push(Command::CreateRelation(created)); + } } } for source in &selected { diff --git a/crates/oa-core/src/validation.rs b/crates/oa-core/src/validation.rs index cd7d97ef..99609443 100644 --- a/crates/oa-core/src/validation.rs +++ b/crates/oa-core/src/validation.rs @@ -450,21 +450,7 @@ pub fn validate(p: &Project) -> Vec { }); } } - SharedBody::Port { interface, .. } => { - if let Some(id) = interface { - expect_element(p, &mut out, e.id, *id, "interface", |k| { - k == Kind::Interface - }); - } else { - warning( - &mut out, - e.id, - "missing_interface", - "interface", - "Choose a port interface or add an explicit provision/requirement contract.", - ); - } - } + SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out), SharedBody::Scenario { viewpoint, context, @@ -2140,82 +2126,7 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec) { for item in r.body.items() { expect_element(p, out, r.id, *item, "items", |k| k == Kind::ExchangeItem); } - if let RelationBody::FunctionalExchange { - source_port, - target_port, - .. - } = &r.body - { - for (port, owner, outgoing, field) in [ - (*source_port, source, true, "source_port"), - (*target_port, target, false, "target_port"), - ] { - if let Some(port) = port { - match p.elements.get(&port) { - Some(e) - if e.parent == Some(owner) - && matches!(e.shared(),Some(SharedBody::Port{direction,..})if if outgoing{*direction!=PortDirection::In}else{*direction!=PortDirection::Out}) => - {} - _ => error( - out, - r.id, - "functional_port", - field, - "Functional port must belong to the endpoint function with compatible direction.", - ), - } - } - } - } - if r.kind() == RelationKind::BoundaryExchange { - for (e, outgoing, field) in [(a, true, "source"), (b, false, "target")] { - match e.shared() { - Some(SharedBody::Port { direction, .. }) - if e.parent - .and_then(|id| p.elements.get(&id)) - .is_some_and(|e| e.kind().is_performer()) - && (if outgoing { - *direction != PortDirection::In - } else { - *direction != PortDirection::Out - }) => {} - _ => error( - out, - r.id, - "boundary_port", - field, - "Boundary exchange uses participant ports with output/input compatible direction.", - ), - } - } - let interface_items = |port: &Element| { - if let Some(SharedBody::Port { - interface: Some(id), - .. - }) = port.shared() - && let Some(SharedBody::Interface { items }) = - p.elements.get(id).and_then(Element::shared) - { - return Some(items); - } - None - }; - for port in [a, b] { - if let Some(items) = interface_items(port) { - for item in r.body.items() { - if !items.contains(item) { - error( - out, - r.id, - "interface_contract", - "items", - "Exchange item is not included in a referenced port interface.", - ); - } - } - } - } - } + crate::ports::validate_port_relation(p, r, out); if let RelationBody::StateTransition { trigger, guard, @@ -2417,51 +2328,7 @@ pub fn allocation_summary(p: &Project, work: Id) -> Option { } fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec) { - let (Some(boundary), Some(work)) = (p.relations.get(&r.source()), p.relations.get(&r.target())) - else { - return; - }; - for (function, port, field) in [ - (work.source(), boundary.source(), "source"), - (work.target(), boundary.target(), "target"), - ] { - let performer = p - .relations - .values() - .find(|a| a.kind() == RelationKind::Allocation && a.target() == function) - .map(Relation::source); - let owner = p.elements.get(&port).and_then(|e| e.parent); - if let (Some(performer), Some(owner)) = (performer, owner) { - if performer != owner { - error( - out, - r.id, - "exchange_allocation_performer", - field, - "Boundary port owner must perform the corresponding work-exchange endpoint.", - ); - } - } else { - warning( - out, - r.id, - "incomplete_exchange_allocation", - field, - "Allocate work endpoints to the boundary-port participants.", - ); - } - } - for item in work.body.items() { - if !boundary.body.items().contains(item) { - error( - out, - r.id, - "exchange_allocation_items", - "items", - "The boundary exchange must carry every item of the allocated work exchange.", - ); - } - } + crate::ports::validate_exchange_allocation(p, r, out); } pub fn effective_performer(p: &Project, work: Id) -> Option { @@ -2534,59 +2401,6 @@ fn validate_relation_context(p: &Project, r: &Relation, out: &mut Vec) { "A boundary exchange connects different participants.", ); } - let ports: Vec<_> = match &r.body { - RelationBody::BoundaryExchange { source, target, .. } => vec![*source, *target], - RelationBody::FunctionalExchange { - source_port, - target_port, - .. - } => [*source_port, *target_port].into_iter().flatten().collect(), - _ => vec![], - }; - for port in ports { - let mut interfaces = BTreeSet::new(); - if let Some(SharedBody::Port { - interface: Some(id), - .. - }) = p.elements.get(&port).and_then(Element::shared) - { - interfaces.insert(*id); - } - interfaces.extend( - p.relations - .values() - .filter(|rel| { - matches!( - rel.kind(), - RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement - ) && rel.source() == port - }) - .map(Relation::target), - ); - if !interfaces.is_empty() { - let allowed: BTreeSet<_> = interfaces - .iter() - .filter_map(|id| p.elements.get(id)) - .filter_map(|e| match e.shared() { - Some(SharedBody::Interface { items }) => Some(items), - _ => None, - }) - .flatten() - .copied() - .collect(); - for item in r.body.items() { - if !allowed.contains(item) { - error( - out, - r.id, - "port_interface_items", - "items", - "The exchange carries an item outside a port's declared interface contract.", - ); - } - } - } - } if matches!( r.kind(), RelationKind::Availability | RelationKind::StateAction diff --git a/crates/oa-core/tests/release_ports.rs b/crates/oa-core/tests/release_ports.rs new file mode 100644 index 00000000..30b3a6e9 --- /dev/null +++ b/crates/oa-core/tests/release_ports.rs @@ -0,0 +1,601 @@ +use oa_core::{projection::*, transitions::*, validation, *}; + +fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id { + p.add_element(kind, layer, name, None).unwrap() +} +fn request(p: &Project, source: Id, target: Id, items: Vec) -> FunctionalExchangeRequest { + let id = Id::new_v4(); + FunctionalExchangeRequest { + id, + alias: p.fresh_alias("message", "exchange", id), + name: "Message".into(), + description: "Engineering rationale".into(), + source, + target, + source_port: PortChoice::Auto, + target_port: PortChoice::Auto, + items, + } +} +fn fixture() -> (Project, Id, Id, Id, Id) { + let mut p = Project::empty(); + let a = add(&mut p, Kind::Function, Layer::Sa, "Send"); + let b = add(&mut p, Kind::Function, Layer::Sa, "Receive"); + let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Information"); + let request = request(&p, a, b, vec![item]); + p = execute_batch(&p, &plan_functional_exchange(&p, &request).unwrap()).unwrap(); + (p, a, b, item, request.id) +} +fn endpoints(p: &Project, id: Id) -> (Id, Id) { + match p.relations[&id].body { + RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } => (source_port, target_port), + _ => panic!("not a functional exchange"), + } +} +fn allocate(p: &mut Project, a: Id, b: Id) -> (Id, Id) { + let first = add(p, Kind::System, Layer::Sa, "System"); + let second = add(p, Kind::SystemActor, Layer::Sa, "Partner"); + p.relate(RelationKind::Allocation, "Send allocation", first, a); + p.relate(RelationKind::Allocation, "Receive allocation", second, b); + (first, second) +} +fn component_request(p: &Project, fe: Id, kind: ComponentPortKind) -> ComponentExchangeRequest { + let id = Id::new_v4(); + ComponentExchangeRequest { + functional_exchange: fe, + boundary: ComponentExchangeChoice::Create { + id, + alias: p.fresh_alias("boundary", "exchange", id), + name: "Boundary".into(), + description: "Physical realization".into(), + port_kind: kind, + oriented: kind == ComponentPortKind::Flow, + source_port: PortChoice::Auto, + target_port: PortChoice::Auto, + }, + } +} +fn boundary_id(request: &ComponentExchangeRequest) -> Id { + match request.boundary { + ComponentExchangeChoice::Create { id, .. } | ComponentExchangeChoice::Reuse(id) => id, + } +} +fn code(p: &Project, expected: &str) -> bool { + validation::validate(p) + .iter() + .any(|issue| issue.code == expected && issue.severity == validation::Severity::Error) +} + +#[test] +fn functional_creation_reuse_and_item_sync_are_atomic_and_identity_safe() { + let (p, a, b, item, fe) = fixture(); + let (output, input) = endpoints(&p, fe); + assert!( + matches!(port_contract(&p.elements[&output]), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items == &[item]) + ); + assert!( + matches!(port_contract(&p.elements[&input]), Some(PortContract::FunctionInput { incoming_items }) if incoming_items == &[item]) + ); + let next = request(&p, a, b, vec![item]); + let commands = plan_functional_exchange(&p, &next).unwrap(); + assert_eq!(commands.len(), 1); + let mut session = Session::new(p.clone()); + session.execute(Command::Batch(commands)).unwrap(); + assert_eq!(endpoints(session.project(), next.id), (output, input)); + assert_eq!(session.revision(), 1); + session.undo(); + assert_eq!(session.project(), &p); + let mut wrong = request(&p, a, b, vec![item]); + wrong.source_port = PortChoice::Reuse(input); + assert!(plan_functional_exchange(&p, &wrong).is_err()); + assert!(plan_function_port_items(&p, output, vec![]).is_err()); + assert_eq!(session.project(), &p); + assert!(ElementBody::new(Kind::Port, Layer::Sa).is_none()); +} + +#[test] +fn ambiguous_reuse_creates_a_fresh_port_and_collisions_never_overwrite() { + let (mut p, a, b, item, _) = fixture(); + let other = Id::new_v4(); + p.elements.insert( + other, + make_port( + &p, + other, + a, + "Alternative output", + PortContract::FunctionOutput { + outgoing_items: vec![item], + }, + ) + .unwrap(), + ); + let req = request(&p, a, b, vec![item]); + let expected = Id::new_v5(&req.id, b"function-source-port"); + let commands = plan_functional_exchange(&p, &req).unwrap(); + assert_eq!( + serde_json::to_value(&commands).unwrap(), + serde_json::to_value(plan_functional_exchange(&p, &req).unwrap()).unwrap() + ); + let result = execute_batch(&p, &commands).unwrap(); + assert_eq!(endpoints(&result, req.id).0, expected); + let occupied = p.elements[&other].clone(); + let mut blocker = occupied.clone(); + blocker.id = expected; + blocker.alias = "block_generated_identity".into(); + p.elements.insert(expected, blocker); + assert!( + plan_functional_exchange(&p, &req) + .unwrap_err() + .0 + .contains("port_id_collision") + ); + assert_eq!(p.elements[&other], occupied); +} + +#[test] +fn component_generation_requires_real_allocations_and_reuses_existing_facts() { + let (mut p, a, b, _, fe) = fixture(); + let initial = component_request(&p, fe, ComponentPortKind::Flow); + assert!( + plan_component_exchange(&p, &initial) + .unwrap_err() + .0 + .contains("port_allocation_owner") + ); + allocate(&mut p, a, b); + let req = component_request(&p, fe, ComponentPortKind::Flow); + let boundary = boundary_id(&req); + let result = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap(); + assert_eq!( + result + .relations + .values() + .filter(|r| r.kind() == RelationKind::PortAllocation) + .count(), + 2 + ); + assert_eq!( + result + .relations + .values() + .filter(|r| r.kind() == RelationKind::ExchangeAllocation) + .count(), + 1 + ); + let again = ComponentExchangeRequest { + functional_exchange: fe, + boundary: ComponentExchangeChoice::Reuse(boundary), + }; + assert!(plan_component_exchange(&result, &again).unwrap().is_empty()); + let mut wrong = result.clone(); + let source = wrong.relations[&boundary].source(); + if let Some(SharedBody::Port { contract, .. }) = + wrong.elements.get_mut(&source).unwrap().shared_mut() + { + *contract = PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: ComponentPortOrientation::In, + }; + } + assert!(code(&wrong, "boundary_port")); + assert!(code(&wrong, "port_allocation_contract")); + let mut session = Session::new(p.clone()); + session + .execute(Command::Batch(plan_component_exchange(&p, &req).unwrap())) + .unwrap(); + session.undo(); + assert_eq!(session.project(), &p); +} + +#[test] +fn standard_interface_roles_do_not_follow_message_direction_and_kind_mixing_is_rejected() { + let (mut p, a, b, item, fe) = fixture(); + allocate(&mut p, a, b); + let req = component_request(&p, fe, ComponentPortKind::Standard); + let boundary = boundary_id(&req); + p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap(); + let (source, target) = p.relations[&boundary].body.endpoints(); + let interface = add(&mut p, Kind::Interface, Layer::Sa, "Service"); + *p.elements + .get_mut(&interface) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Interface { items: vec![item] }; + let requirement = p.relate( + RelationKind::InterfaceRequirement, + "Caller requires service", + source, + interface, + ); + let provider = p.relate( + RelationKind::InterfaceProvision, + "Callee provides service", + target, + interface, + ); + validate_candidate(&p).unwrap(); + assert!( + !validation::validate(&p) + .iter() + .any(|i| i.code == "unsatisfied_interface_requirement") + ); + assert!(matches!( + p.relations[&boundary].body, + RelationBody::BoundaryExchange { + oriented: false, + .. + } + )); + let without_provider = execute_batch(&p, &[Command::Delete(provider)]).unwrap(); + assert!( + validation::validate(&without_provider).iter().any( + |i| i.element == Some(requirement) && i.code == "unsatisfied_interface_requirement" + ) + ); + if let Some(SharedBody::Port { contract, .. }) = + p.elements.get_mut(&source).unwrap().shared_mut() + { + *contract = PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: ComponentPortOrientation::Out, + }; + } + assert!(code(&p, "mixed_component_ports")); +} + +#[test] +fn legacy_split_preserves_original_identity_prose_interface_artifact_constraint_and_trace() { + let (mut p, a, b, item, forward) = fixture(); + let (old, _) = endpoints(&p, forward); + let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original interface"); + *p.elements + .get_mut(&interface) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Interface { items: vec![item] }; + p.elements.get_mut(&old).unwrap().description = "Original bidirectional rationale".into(); + *p.elements.get_mut(&old).unwrap().shared_mut().unwrap() = SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::InOut, + interface: Some(interface), + }), + }; + let reverse_req = request(&p, b, a, vec![item]); + p = execute_batch(&p, &plan_functional_exchange(&p, &reverse_req).unwrap()).unwrap(); + if let RelationBody::FunctionalExchange { target_port, .. } = + &mut p.relations.get_mut(&reverse_req.id).unwrap().body + { + *target_port = old; + } + let artifact = Id::new_v4(); + p.artifact_links.insert( + artifact, + ArtifactLink { + id: artifact, + alias: "rationale".into(), + name: "Evidence".into(), + description: "Attached original evidence".into(), + owner: old, + relation: ArtifactRelation::References, + uri: "docs/evidence.md".into(), + revision: Some("v2".into()), + fragment: None, + }, + ); + let constraint = add(&mut p, Kind::Constraint, Layer::Sa, "Port requirement"); + *p.elements + .get_mut(&constraint) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Constraint { + expression: "original meaning".into(), + constrained: vec![old], + }; + let oa_work = add(&mut p, Kind::Activity, Layer::Oa, "Original work"); + let oa_port = Id::new_v4(); + let mut legacy_oa = p.elements[&old].clone(); + legacy_oa.id = oa_port; + legacy_oa.alias = "original_oa_port".into(); + legacy_oa.parent = Some(oa_work); + legacy_oa.body = ElementBody::shared_in( + Layer::Oa, + SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::InOut, + interface: None, + }), + }, + ); + p.elements.insert(oa_port, legacy_oa); + let trace = Id::new_v4(); + p.traces.insert( + trace, + Trace { + id: trace, + alias: "port_trace".into(), + name: "Original correspondence".into(), + description: String::new(), + oa: oa_port, + sa: old, + kind: TraceKind::Item, + baseline: None, + }, + ); + validate_candidate(&p).unwrap(); + let req = LegacyWorkPortSplitRequest { + port: old, + primary: FunctionPortSide::Input, + incoming_items: vec![item], + outgoing_items: vec![item], + }; + let commands = plan_legacy_work_port_split(&p, &req).unwrap(); + let mut session = Session::new(p.clone()); + session.execute(Command::Batch(commands)).unwrap(); + let result = session.project(); + let opposite = Id::new_v5(&old, b"split-opposite-port"); + assert_eq!(endpoints(result, forward).0, opposite); + assert_eq!(endpoints(result, reverse_req.id).1, old); + assert_eq!( + result.elements[&old].description, + p.elements[&old].description + ); + assert!( + matches!(result.elements[&old].shared(), Some(SharedBody::Port { contract: PortContract::FunctionInput { .. }, legacy: Some(LegacyPortContract { direction: PortDirection::InOut, interface: Some(id) }) }) if *id == interface) + ); + assert_eq!( + result.artifact_links[&artifact], + p.artifact_links[&artifact] + ); + assert_eq!(result.traces[&trace], p.traces[&trace]); + assert_eq!(result.elements[&constraint], p.elements[&constraint]); + session.undo(); + assert_eq!(session.project(), &p); + assert!( + plan_legacy_port_resolution( + &p, + &LegacyPortResolution { + port: oa_port, + contract: PortContract::FunctionInput { + incoming_items: vec![] + } + } + ) + .is_err() + ); +} + +#[test] +fn deletion_preserves_independent_ports_but_removes_required_dependents() { + let (mut p, a, b, _, fe) = fixture(); + allocate(&mut p, a, b); + let req = component_request(&p, fe, ComponentPortKind::Flow); + p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap(); + let (output, input) = endpoints(&p, fe); + let allocation = p + .relations + .values() + .find(|r| r.kind() == RelationKind::PortAllocation) + .unwrap() + .id; + let removed_allocation = execute_batch(&p, &[Command::Delete(allocation)]).unwrap(); + assert!(removed_allocation.elements.contains_key(&output)); + assert!( + removed_allocation + .relations + .values() + .all(|r| r.kind() != RelationKind::ExchangeAllocation) + ); + let removed_fe = execute_batch(&p, &[Command::Delete(fe)]).unwrap(); + assert!(removed_fe.elements.contains_key(&output) && removed_fe.elements.contains_key(&input)); + let removed_port = execute_batch(&p, &[Command::Delete(output)]).unwrap(); + assert!(!removed_port.relations.contains_key(&fe)); + assert!(removed_port.elements.contains_key(&input)); + let new = Id::new_v4(); + let changed = execute_batch(&p, &[Command::Reidentify(output, new)]).unwrap(); + assert_eq!(endpoints(&changed, fe).0, new); + assert!( + changed + .relations + .values() + .any(|r| r.kind() == RelationKind::PortAllocation && r.target() == new) + ); +} + +#[test] +fn directed_legacy_resolution_retains_unclassified_interface_and_role_identity() { + let (mut p, _, _, item, fe) = fixture(); + let output = endpoints(&p, fe).0; + let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original service"); + *p.elements + .get_mut(&interface) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Interface { items: vec![item] }; + *p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::Out, + interface: Some(interface), + }), + }; + let role = p.relate( + RelationKind::InterfaceRequirement, + "Original unreviewed role", + output, + interface, + ); + let req = LegacyPortResolution { + port: output, + contract: PortContract::FunctionOutput { + outgoing_items: vec![item], + }, + }; + let result = execute_batch(&p, &plan_legacy_port_resolution(&p, &req).unwrap()).unwrap(); + assert_eq!(result.relations[&role], p.relations[&role]); + assert_eq!(result.elements[&output].alias, p.elements[&output].alias); + assert!( + validation::validate(&result) + .iter() + .any(|issue| issue.element == Some(role) && issue.code == "legacy_interface_role") + ); + let replacement = Id::new_v4(); + let remapped = execute_batch(&result, &[Command::Reidentify(interface, replacement)]).unwrap(); + assert!( + matches!(remapped.elements[&output].shared(), Some(SharedBody::Port { legacy: Some(LegacyPortContract { interface: Some(id), .. }), .. }) if *id == replacement) + ); + let removed = execute_batch(&remapped, &[Command::Delete(replacement)]).unwrap(); + assert!(removed.elements.contains_key(&item)); + assert!(!removed.relations.contains_key(&role)); + assert!( + matches!(removed.elements[&output].shared(), Some(SharedBody::Port { contract: PortContract::FunctionOutput { outgoing_items }, legacy: Some(LegacyPortContract { interface: None, .. }) }) if outgoing_items == &[item]) + ); +} + +#[test] +fn transitions_create_sa_ports_and_omit_sa_implementation_from_portless_oa() { + let mut oa = Project::empty(); + let a = add(&mut oa, Kind::Activity, Layer::Oa, "Send"); + let b = add(&mut oa, Kind::Activity, Layer::Oa, "Receive"); + let interaction = oa.relate(RelationKind::Interaction, "Operational exchange", a, b); + let mut req = TransitionRequest::new(TransitionDirection::OaToSa, vec![interaction]); + req.scope = DependencyScope::Required; + let plan = plan_transition(&oa, &req).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let sa = apply_transition(&oa, &plan).unwrap(); + assert_eq!( + sa.elements + .values() + .filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Sa) + .count(), + 2 + ); + assert_eq!( + sa.elements + .values() + .filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa) + .count(), + 0 + ); + let (mut p, a, b, _, fe) = fixture(); + allocate(&mut p, a, b); + let component_req = component_request(&p, fe, ComponentPortKind::Flow); + let boundary = boundary_id(&component_req); + p = execute_batch(&p, &plan_component_exchange(&p, &component_req).unwrap()).unwrap(); + let mut reverse = TransitionRequest::new(TransitionDirection::SaToOa, vec![fe, boundary]); + reverse.scope = DependencyScope::Required; + let plan = plan_transition(&p, &reverse).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission"))); + let result = apply_transition(&p, &plan).unwrap(); + assert_eq!( + result + .elements + .values() + .filter(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa) + .count(), + 0 + ); + assert!( + result + .relations + .values() + .any(|r| r.kind() == RelationKind::Interaction) + ); + for e in p.elements.values() { + assert_eq!(result.elements[&e.id], *e); + } + for r in p.relations.values() { + assert_eq!(result.relations[&r.id], *r); + } + // A required FE dependency is portless in OA even while an SA endpoint + // retains unresolved legacy semantics. No incidental legacy copy is made. + let (mut legacy, _, _, _, legacy_fe) = fixture(); + let legacy_port = endpoints(&legacy, legacy_fe).0; + *legacy + .elements + .get_mut(&legacy_port) + .unwrap() + .shared_mut() + .unwrap() = SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::InOut, + interface: None, + }), + }; + let mut req = TransitionRequest::new(TransitionDirection::SaToOa, vec![legacy_fe]); + req.scope = DependencyScope::Required; + let plan = plan_transition(&legacy, &req).unwrap(); + assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts); + let result = apply_transition(&legacy, &plan).unwrap(); + assert!( + !result + .elements + .values() + .any(|e| e.kind() == Kind::Port && e.layer() == Layer::Oa) + ); +} + +#[test] +fn projection_anchors_are_contract_aware_and_unoriented_connections_keep_their_identity() { + let (mut p, a, b, _, fe) = fixture(); + let (system, actor) = allocate(&mut p, a, b); + let req = component_request(&p, fe, ComponentPortKind::Standard); + let boundary = boundary_id(&req); + p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap(); + let diagram = p.ensure_diagram(Layer::Sa); + for (index, id) in [system, actor, a, b].into_iter().enumerate() { + p.place( + diagram, + id, + Position { + x: 50. + index as f32 * 280., + y: 100., + }, + ); + } + let view = project_view(&p, diagram); + let edge = view.edges.iter().find(|e| e.id == boundary).unwrap(); + assert!(!edge.oriented); + assert_eq!(edge.source_port, Some(p.relations[&boundary].source())); + assert!( + view.ports + .iter() + .filter(|port| [ + p.relations[&boundary].source(), + p.relations[&boundary].target() + ] + .contains(&port.element)) + .all(|port| port.side == PortAnchorSide::Bottom) + ); + let (output, input) = endpoints(&p, fe); + assert_eq!( + view.ports + .iter() + .find(|port| port.element == output) + .unwrap() + .side, + PortAnchorSide::Right + ); + assert_eq!( + view.ports + .iter() + .find(|port| port.element == input) + .unwrap() + .side, + PortAnchorSide::Left + ); + assert!( + p.diagrams[&diagram] + .nodes + .values() + .all(|node| p.elements[&node.element].kind() != Kind::Port) + ); +} diff --git a/crates/oa-core/tests/release_scenarios.rs b/crates/oa-core/tests/release_scenarios.rs index 01b142f9..5e45cdf9 100644 --- a/crates/oa-core/tests/release_scenarios.rs +++ b/crates/oa-core/tests/release_scenarios.rs @@ -122,6 +122,26 @@ fn fixture(layer: Layer) -> Fixture { | RelationBody::FunctionalExchange { items, .. } => items.push(item), _ => unreachable!(), } + if let RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } = p.relations[&exchange].body + { + for port in [source_port, target_port] { + match p.elements.get_mut(&port).unwrap().shared_mut() { + Some(SharedBody::Port { + contract: PortContract::FunctionInput { incoming_items }, + .. + }) => incoming_items.push(item), + Some(SharedBody::Port { + contract: PortContract::FunctionOutput { outgoing_items }, + .. + }) => outgoing_items.push(item), + _ => unreachable!(), + } + } + } let call = add_occurrence( &mut p, scenario, @@ -1010,9 +1030,27 @@ fn role_lifelines_require_explicit_work_assignment_not_just_a_shared_realizing_e validation::semantic_representation_compatible(&p, target, work), Some(false) ); - let port = p - .add_element(Kind::Port, Layer::Oa, "Controller port", Some(entity)) - .unwrap(); + let port = Id::new_v4(); + p.elements.insert( + port, + Element { + id: port, + alias: "legacy_controller_port".into(), + name: "Controller port".into(), + description: String::new(), + parent: Some(entity), + body: ElementBody::shared_in( + Layer::Oa, + SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::InOut, + interface: None, + }), + }, + ), + }, + ); assert_eq!( validation::semantic_representation_compatible(&p, entity, port), Some(true) diff --git a/crates/oa-core/tests/workflows.rs b/crates/oa-core/tests/workflows.rs index b4e511dc..7a57dbd6 100644 --- a/crates/oa-core/tests/workflows.rs +++ b/crates/oa-core/tests/workflows.rs @@ -334,11 +334,29 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() { .shared_mut() .unwrap() = SharedBody::Interface { items: vec![item] }; let port = |p: &mut Project, name: &str, parent, direction| { - let id = add(p, Kind::Port, Layer::Sa, name, Some(parent)); - *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port { - direction, - interface: Some(interface), + let id = Id::new_v4(); + let contract = if p.elements[&parent].kind() == Kind::Function { + if direction == PortDirection::Out { + PortContract::FunctionOutput { + outgoing_items: vec![item], + } + } else { + PortContract::FunctionInput { + incoming_items: vec![item], + } + } + } else { + PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: if direction == PortDirection::Out { + ComponentPortOrientation::Out + } else { + ComponentPortOrientation::In + }, + } }; + p.elements + .insert(id, make_port(p, id, parent, name, contract).unwrap()); id }; let out = port(&mut p, "System output", system, PortDirection::Out); @@ -355,10 +373,14 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() { p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange { source: f1, target: f2, - source_port: Some(fp1), - target_port: Some(fp2), + source_port: fp1, + target_port: fp2, items: vec![item], }; + p.relate(RelationKind::Allocation, "Send allocation", system, f1); + p.relate(RelationKind::Allocation, "Receive allocation", actor, f2); + p.relate(RelationKind::PortAllocation, "Output allocation", out, fp1); + p.relate(RelationKind::PortAllocation, "Input allocation", input, fp2); p.relate( RelationKind::ExchangeAllocation, "Carried on", @@ -381,7 +403,7 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() { if let RelationBody::FunctionalExchange { source_port, .. } = &mut p.relations.get_mut(&exchange).unwrap().body { - *source_port = Some(input); + *source_port = input; } rejected(&p, "functional_port"); } @@ -721,15 +743,36 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() { }; let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None); let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None); - let port = add(&mut p, Kind::Port, Layer::Sa, "Output", Some(function)); + let port = Id::new_v4(); + p.elements.insert( + port, + make_port( + &p, + port, + function, + "Output", + PortContract::FunctionOutput { + outgoing_items: vec![item], + }, + ) + .unwrap(), + ); let rel = p.relate(RelationKind::FunctionalExchange, "Data", function, other); if let RelationBody::FunctionalExchange { source_port, items, .. } = &mut p.relations.get_mut(&rel).unwrap().body { - *source_port = Some(port); + *source_port = port; items.push(item); } + if let RelationBody::FunctionalExchange { target_port, .. } = p.relations[&rel].body + && let Some(SharedBody::Port { + contract: PortContract::FunctionInput { incoming_items }, + .. + }) = p.elements.get_mut(&target_port).unwrap().shared_mut() + { + incoming_items.push(item); + } let dtype_new = Id::new_v4(); let port_new = Id::new_v4(); let result = execute_batch( @@ -744,7 +787,7 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() { matches!(result.elements[&field].shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(id),..})if *id==dtype_new) ); assert!( - matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:Some(id),..}if *id==port_new) + matches!(&result.relations[&rel].body,RelationBody::FunctionalExchange{source_port:id,..}if *id==port_new) ); } diff --git a/harness/release-port-core.md b/harness/release-port-core.md new file mode 100644 index 00000000..bd153f5f --- /dev/null +++ b/harness/release-port-core.md @@ -0,0 +1,46 @@ +# Typed port core milestone handoff + +Implemented in `D:/CodexWorktrees/Capella2-typed-ports`, branch `codex/typed-port-contracts`, based on `c20a207`. This is the core portion of [the adopted port contract](release-port-contract.md). It must be integrated with the migration/source worker's companion changes; desktop/native acceptance remains the root task's work. + +## Delivered behavior + +- Schema 3 persists `PortContract::{FunctionInput, FunctionOutput, Component, Legacy}`, preserved `LegacyPortContract`, Standard/Flow component kind and separate orientation, mandatory FE port UUIDs, `BoundaryExchange.oriented`, and identified `PortAllocation` relations. Legacy association data is preserved through typed resolution and remains reference-aware without becoming an invented provided/required declaration. +- Typed function/component ownership, direct item declarations, empty-contract handling, actual work allocation, endpoint direction, same-layer references, unique PortAllocation pairs, explicit interface roles, and exchange/port allocation correspondence are validated in `ports.rs` through the normal candidate validator. Standard-to-Flow component exchanges are structural errors, reflecting the root's later native observation. Legacy compatibility remains explicitly warned. +- Public `plan_functional_exchange`, `plan_component_exchange`, `plan_legacy_port_resolution`, and `plan_legacy_work_port_split` implement the contract request types and return concrete existing commands. Public `plan_function_port_items` supplies explicit item synchronization. Planners validate complete candidate batches; intermediate generation does not modify the original project. +- FE creation reuses exactly one compatible typed endpoint or creates deterministic new endpoints. Explicit incompatible reuse fails; ambiguous Auto creates a fresh port. Component generation creates/reuses both component ports, two PortAllocations, the boundary exchange, and ExchangeAllocation together. It requires actual work allocations and never invents interfaces or provider/requirement roles. Repeated generation with an existing boundary reuses facts and identities. +- Split preserves the original legacy record, primary UUID/alias/prose, artifacts, constraints, and traces; it creates one opposite typed port and rewires source/target FE references. Resolution retains original interface metadata and legacy role identities. API/source can represent preserved legacy roles on resolved ports, with clear warnings; normal typed function ports use item contracts. +- Reidentify visits typed item declarations, required FE endpoints, legacy interface associations, and allocation relations. Deleting a function port cascades incident FEs; deleting an FE retains independent ports. Deleting a work Allocation cascades dependent PortAllocations, and deleting a required PortAllocation cascades its dependent ExchangeAllocation. Interface deletion clears the preserved optional association and removes role relations while retaining items/ports. +- OA-to-SA Interaction creation creates typed SA endpoints through the FE builder. SA-to-OA FE mapping remains portless, and Required scope does not copy even Legacy SA endpoint ports solely because an FE used them. Typed SA ports, boundary implementation, and dependent role/allocation records have explicit omission warnings. Original SA objects remain intact. Existing counterpart updates retain their endpoint identities. +- The demo now uses typed ports, explicit function/component port allocations, and port-level interface roles. Projection anchors derive from visible owners without requiring port placements; orientation is independent of anchor side and actual exchange endpoint IDs are retained. + +## Integration APIs and changed rendering fields + +All port helpers and request types are re-exported through `oa_core::*`; the bounded implementation module is `oa_core::ports`. + +- `make_port(project, id, owner, name, contract) -> Result` builds an explicit port element for a caller's transaction. It does not adopt a model or independently validate unrelated candidate state. +- `ElementBody::new(Kind::Port, ..)` now returns None: contextual authoring must choose a typed contract and owner. Do not restore a generic InOut default to make old forms compile. +- `RelationBody::new(FunctionalExchange, ..)` is an explicitly unbound draft with nil endpoint IDs; it cannot pass candidate validation. Normal GUI/API authoring should use the public planner or a concrete complete Batch. `Command::Connect(FunctionalExchange, ..)` and `Project::relate(FunctionalExchange, ..)` now create/reuse real endpoint ports. The latter remains the existing unchecked Project builder; Session validates its resulting transaction. +- `ProjectedPort.direction` is replaced by `side: PortAnchorSide::{Left, Right, Bottom}`. `projection::port_anchor_side(&Element)` is public. Glyph semantics must inspect the element's actual PortContract; Standard/Unset and InOut Flow are not interchangeable. +- `ProjectedEdge` adds `oriented: bool`; BoundaryExchange uses its persisted value and other edges use true. The root's newer main-only `summary: bool`, routing, and layout changes must be merged alongside this field, not overwritten by this older checkpoint's projection body. +- `item_satisfies` is identity equality until R6 specialization lands. `items_satisfy`, `actual_performer`, `component_allows`, `has_port_allocation`, and `port_contract` provide the small shared validation/authoring seams. + +Preview binding to the current revision/fingerprint and UI display of non-FE references during split belong to the existing desktop preview/usage surface. Port planners return concrete commands; they do not introduce a second preview transaction engine. The UI must present preserved legacy associations distinctly and expose source/form/connection paths using these helpers. + +## Verification + +Build output is isolated at `D:/CodexBuilds/Capella2-typed-ports-core`. + +- `cargo test -p oa-core`: all 124 tests passed after core/source/migration integration, including unchanged legacy admission/location tests. One additional directed-legacy-resolution regression was then added to the focused port suite. +- `cargo test -p oa-core --test release_ports`: all 9 focused tests passed, covering atomic creation/reuse/collisions, actual allocation/generation, provider/requirement independence, Standard/Flow rejection, Legacy split and resolution, preserved attachments and references, deletion/remap/undo, portless transitions, and contract-aware geometry. +- `cargo clippy -p oa-core --all-targets -- -D warnings`: passed after the final core/test changes. +- Existing scenario14, diagram3, and workflow28 suites pass, including remapping/deleting every demo identity and checking the remaining candidate. Source/migration tests and server checks are owned/reported by the companion worker. + +No CUA, desktop/native acceptance, deployment, future R6 item specialization, or R3 semantic baseline engine is claimed. Current R3 name/description-only drift remains the separately documented release seam; this milestone does not disguise it as completed semantic review. + +## Owned commit scope + +Core: `model.rs`, `ports.rs`, `lib.rs`, `validation.rs`, `references.rs`, `session.rs`, `transitions.rs`, `demo.rs`, `projection.rs`. + +Tests: new `release_ports.rs`; typed fixture adaptations in `workflows.rs` and `release_scenarios.rs`. + +Handoff: this file only. Migration/source/server/documentation files and their tests belong to the companion worker and are intentionally excluded from the core commit.