Implement bounded typed data and identified interface contracts

This commit is contained in:
2026-09-06 03:44:09 +03:00
parent 1230ab8a3e
commit 0b58107097
15 changed files with 3127 additions and 177 deletions
File diff suppressed because it is too large Load Diff
+24 -11
View File
@@ -34,10 +34,7 @@ fn occurrence(
id
}
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
let items = match p.elements[&interface].shared() {
Some(SharedBody::Interface { items }) => items.clone(),
_ => vec![],
};
let items = crate::data::interface_items(p, interface);
let contract = if p.elements[&owner].kind() == Kind::Function {
if direction == PortDirection::Out {
PortContract::FunctionOutput {
@@ -168,6 +165,11 @@ pub fn coastal_rescue() -> Project {
"Distress report",
None,
);
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
p.elements.get_mut(&oa_item).unwrap().shared_mut()
{
*mechanism = ExchangeMechanism::Event;
}
p.relate(RelationKind::Allocation, "Reports distress", vessel, report);
p.relate(
RelationKind::Allocation,
@@ -308,6 +310,11 @@ pub fn coastal_rescue() -> Project {
"Digital distress report",
None,
);
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
p.elements.get_mut(&information).unwrap().shared_mut()
{
*mechanism = ExchangeMechanism::Event;
}
let material = add(
&mut p,
Kind::ExchangeItem,
@@ -317,6 +324,8 @@ pub fn coastal_rescue() -> Project {
);
*p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material,
mechanism: ExchangeMechanism::Flow,
supertype: None,
};
let energy = add(
&mut p,
@@ -327,6 +336,8 @@ pub fn coastal_rescue() -> Project {
);
*p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy,
mechanism: ExchangeMechanism::Flow,
supertype: None,
};
let field = add(
&mut p,
@@ -340,6 +351,9 @@ pub fn coastal_rescue() -> Project {
minimum: 1,
maximum: Some(1),
unit: "degrees north".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
let interface = add(
&mut p,
@@ -348,13 +362,12 @@ pub fn coastal_rescue() -> Project {
"Distress reporting interface",
None,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface {
items: vec![information],
};
p.relate(
RelationKind::InterfaceItemAllocation,
"Distress payload",
interface,
information,
);
let vessel_port = port(
&mut p,
actor,
+1
View File
@@ -1,4 +1,5 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod data;
pub mod language;
pub mod model;
pub mod persistence;
+149 -14
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 3;
pub const SCHEMA_VERSION: u32 = 4;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -265,17 +265,79 @@ pub enum ExchangeItemKind {
Information,
Material,
Energy,
Event,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ExchangeMechanism {
#[default]
Unspecified,
Flow,
Event,
Operation,
SharedData,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ParameterDirection {
#[default]
Unspecified,
In,
Out,
InOut,
Return,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FieldStorage {
#[default]
Value,
Reference,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DataValue {
Boolean(bool),
Integer(i64),
Real(f64),
String(String),
Enumeration(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TransmissionProtocol {
#[default]
Unspecified,
Synchronous,
Asynchronous,
Unicast,
Multicast,
Broadcast,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AcquisitionProtocol {
#[default]
Unspecified,
Read,
Accept,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DataTypeDefinition {
Boolean,
Integer,
Real,
String,
Enumeration { literals: Vec<String> },
Record,
Integer {
minimum: Option<i64>,
maximum: Option<i64>,
},
Real {
minimum: Option<f64>,
maximum: Option<f64>,
},
String {
minimum_length: u32,
maximum_length: Option<u32>,
},
Enumeration {
literals: Vec<String>,
},
Record {
supertype: Option<Id>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -453,6 +515,8 @@ pub struct LifelineBinding {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MessageValue {
#[serde(default)]
pub path: Vec<Id>,
pub field: Id,
pub expression: String,
}
@@ -502,18 +566,25 @@ pub enum OccurrenceBody {
pub enum SharedBody {
ExchangeItem {
kind: ExchangeItemKind,
mechanism: ExchangeMechanism,
supertype: Option<Id>,
},
DataType {
definition: DataTypeDefinition,
default: Option<DataValue>,
unit: String,
},
DataField {
type_ref: TypeRef,
minimum: u32,
maximum: Option<u32>,
unit: String,
direction: ParameterDirection,
storage: FieldStorage,
default: Option<Vec<DataValue>>,
},
Interface {
items: Vec<Id>,
supertype: Option<Id>,
},
Port {
contract: PortContract,
@@ -672,17 +743,27 @@ impl ElementBody {
match k {
Kind::ExchangeItem => SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Unspecified,
supertype: None,
},
Kind::DataType => SharedBody::DataType {
definition: DataTypeDefinition::String,
definition: DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
default: None,
unit: String::new(),
},
Kind::DataField => SharedBody::DataField {
type_ref: TypeRef::String,
minimum: 1,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
},
Kind::Interface => SharedBody::Interface { items: vec![] },
Kind::Interface => SharedBody::Interface { supertype: None },
// Port creation requires an explicit contract and owner.
Kind::Port => return None,
Kind::Scenario => SharedBody::Scenario {
@@ -752,6 +833,8 @@ impl SharedBody {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RelationKind {
InterfaceItemAllocation,
DataAssociation,
Allocation,
PortAllocation,
Interaction,
@@ -775,7 +858,9 @@ pub enum RelationKind {
StateAction,
}
impl RelationKind {
pub const ALL: [Self; 21] = [
pub const ALL: [Self; 23] = [
Self::InterfaceItemAllocation,
Self::DataAssociation,
Self::Allocation,
Self::PortAllocation,
Self::Interaction,
@@ -800,6 +885,8 @@ impl RelationKind {
];
pub fn permits(self, from: Kind, to: Kind) -> bool {
match self {
Self::InterfaceItemAllocation => from == Kind::Interface && to == Kind::ExchangeItem,
Self::DataAssociation => from == Kind::DataType && to == Kind::DataType,
Self::Allocation => from.is_performer() && to.is_work(),
Self::PortAllocation => from == Kind::Port && to == Kind::Port,
Self::Interaction => from == Kind::Activity && to == Kind::Activity,
@@ -855,6 +942,8 @@ impl RelationKind {
}
pub fn hint(self) -> &'static str {
match self {
Self::InterfaceItemAllocation => "Interface → allocated exchange item",
Self::DataAssociation => "Record type → record type",
Self::Allocation => "Performer → leaf activity/function",
Self::PortAllocation => "Component port → allocated function port",
Self::Interaction => "OA activity → OA activity",
@@ -886,6 +975,8 @@ impl RelationKind {
impl fmt::Display for RelationKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::InterfaceItemAllocation => "Interface item allocation",
Self::DataAssociation => "Data association",
Self::Allocation => "Allocation",
Self::PortAllocation => "Port allocation",
Self::Interaction => "Interaction",
@@ -922,6 +1013,25 @@ pub struct Relation {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum RelationBody {
InterfaceItemAllocation {
source: Id,
target: Id,
order: u32,
transmission: TransmissionProtocol,
acquisition: AcquisitionProtocol,
},
DataAssociation {
source: Id,
target: Id,
source_role: String,
target_role: String,
source_minimum: u32,
source_maximum: Option<u32>,
target_minimum: u32,
target_maximum: Option<u32>,
source_navigable: bool,
target_navigable: bool,
},
Allocation {
source: Id,
target: Id,
@@ -1039,6 +1149,8 @@ impl Relation {
impl RelationBody {
pub fn kind(&self) -> RelationKind {
match self {
Self::InterfaceItemAllocation { .. } => RelationKind::InterfaceItemAllocation,
Self::DataAssociation { .. } => RelationKind::DataAssociation,
Self::Allocation { .. } => RelationKind::Allocation,
Self::PortAllocation { .. } => RelationKind::PortAllocation,
Self::Interaction { .. } => RelationKind::Interaction,
@@ -1064,7 +1176,9 @@ impl RelationBody {
}
pub fn endpoints(&self) -> (Id, Id) {
match self {
Self::Allocation { source, target }
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
| Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target }
@@ -1089,7 +1203,9 @@ impl RelationBody {
}
pub fn set_endpoints(&mut self, a: Id, b: Id) {
match self {
Self::Allocation { source, target }
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
| Self::PortAllocation { source, target }
| Self::Interaction { source, target, .. }
| Self::CapabilityInvolvement { source, target }
@@ -1125,6 +1241,25 @@ impl RelationBody {
}
pub fn new(kind: RelationKind, source: Id, target: Id) -> Self {
match kind {
RelationKind::InterfaceItemAllocation => Self::InterfaceItemAllocation {
source,
target,
order: 0,
transmission: TransmissionProtocol::Unspecified,
acquisition: AcquisitionProtocol::Unspecified,
},
RelationKind::DataAssociation => Self::DataAssociation {
source,
target,
source_role: String::new(),
target_role: String::new(),
source_minimum: 0,
source_maximum: None,
target_minimum: 0,
target_maximum: None,
source_navigable: false,
target_navigable: false,
},
RelationKind::Allocation => Self::Allocation { source, target },
RelationKind::PortAllocation => Self::PortAllocation { source, target },
RelationKind::Interaction => Self::Interaction {
+8 -18
View File
@@ -64,9 +64,9 @@ pub struct LegacyWorkPortSplitRequest {
pub outgoing_items: Vec<Id>,
}
/// R6 specialization has one explicit seam; current models support identity only.
pub fn item_satisfies(_project: &Project, carried: Id, declared: Id) -> bool {
carried == declared
/// A specialized carried item satisfies a declared base item.
pub fn item_satisfies(project: &Project, carried: Id, declared: Id) -> bool {
crate::data::item_satisfies(project, carried, declared)
}
pub fn items_satisfy(project: &Project, carried: &[Id], declared: &[Id]) -> bool {
carried.iter().all(|item| {
@@ -130,13 +130,7 @@ fn declared_interface_items(p: &Project, port: Id) -> Option<Vec<Id>> {
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()
.flat_map(|id| crate::data::interface_items(p, id))
.collect(),
)
}
@@ -903,14 +897,10 @@ fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mu
..
}) => {
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 let Some(interface) = legacy.as_ref().and_then(|l| l.interface) {
let items = crate::data::interface_items(p, interface);
if r.kind() == RelationKind::BoundaryExchange
&& !items_satisfy(p, r.body.items(), items)
&& !items_satisfy(p, r.body.items(), &items)
{
error(
out,
@@ -1173,7 +1163,7 @@ pub(crate) fn validate_boundary_interface_requirements(
if !p.relations.values().any(|role| {
role.kind() == RelationKind::InterfaceProvision
&& role.source() == peer.id
&& role.target() == required.target()
&& crate::data::interface_satisfies(p, role.target(), required.target())
}) {
warning(
out,
+8 -2
View File
@@ -335,6 +335,11 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
.collect();
let mut edges = Vec::new();
for r in p.relations.values() {
// Interface allocations are selectable nested dictionary rows. Their
// independently identified contracts do not add noisy diagram arrows.
if r.kind() == RelationKind::InterfaceItemAllocation {
continue;
}
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
continue;
}
@@ -370,7 +375,8 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
&& (source != target
|| (d.projection.state_machine().is_some()
|| ((d.projection.state_machine().is_some()
|| r.kind() == RelationKind::DataAssociation)
&& r.source() == r.target()
&& nodes
.iter()
@@ -396,7 +402,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
RelationBody::BoundaryExchange {
oriented: false,
..
}
} | RelationBody::DataAssociation { .. }
),
source_port: source_port.filter(|id| visible_ports.contains(id)),
target_port: target_port.filter(|id| visible_ports.contains(id)),
+43 -10
View File
@@ -33,8 +33,13 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
type_ref: TypeRef::Named(id),
..
} => visit(id),
SharedBody::Interface { items } => {
for id in items {
SharedBody::Interface { supertype }
| SharedBody::ExchangeItem { supertype, .. }
| SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
..
} => {
if let Some(id) = supertype {
visit(id);
}
}
@@ -83,6 +88,9 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
visit(id);
}
for value in values {
for id in &mut value.path {
visit(id);
}
visit(&mut value.field);
}
}
@@ -377,7 +385,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
SharedBody::Interface { supertype }
| SharedBody::ExchangeItem { supertype, .. }
| SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
..
} => clear(supertype),
SharedBody::Event {
body: EventBody::Signal { items },
} => items.retain(|id| !removed.contains(id)),
@@ -413,7 +426,10 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
clear(sender);
clear(receiver);
clear(reply_to);
values.retain(|value| !removed.contains(&value.field));
values.retain(|value| {
!removed.contains(&value.field)
&& value.path.iter().all(|id| !removed.contains(id))
});
}
OccurrenceBody::Action { action, lifeline } => {
clear(action);
@@ -485,20 +501,37 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
exchange: Some(id), ..
},
..
}) => p
.relations
.get(id)
.map(|r| (e.id, crate::validation::carried_fields(p, r))),
}) => p.relations.get(id).map(|r| {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = e.shared()
else {
unreachable!()
};
let retained: Vec<_> = values
.iter()
.filter(|value| {
crate::data::carried_value_path(p, r, &value.path, value.field).is_some()
|| (value.path.is_empty()
&& crate::data::carried_field_paths(p, r)
.iter()
.any(|f| f.field == value.field))
})
.cloned()
.collect();
(e.id, retained)
}),
_ => None,
})
.collect();
for (id, fields) in allowed {
for (id, retained) in allowed {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
{
values.retain(|value| fields.contains(&value.field));
*values = retained;
}
}
}
+128 -3
View File
@@ -1,7 +1,7 @@
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{Diagram, Id, Position, Project, RelationKind, Size};
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -55,6 +55,122 @@ pub struct EdgeLabelGeometry {
pub bounds: RoutingBounds,
}
/// Derived labels and navigation markers for the two named association ends.
/// Canvas and SVG consume these same coordinates; no extra diagram state exists.
#[derive(Debug, Clone, PartialEq)]
pub struct AssociationEndGeometry {
pub source_end: bool,
pub label: EdgeLabelGeometry,
pub tip: Position,
pub inward: Position,
pub navigable: bool,
}
pub fn association_end_geometry(
relation: &Relation,
routed: &RoutedEdge,
) -> Vec<AssociationEndGeometry> {
let RelationBody::DataAssociation {
source_navigable,
target_navigable,
..
} = &relation.body
else {
return Vec::new();
};
let mut ends: Vec<AssociationEndGeometry> = Vec::new();
for (source_end, navigable) in [(true, *source_navigable), (false, *target_navigable)] {
let Some(tip) = (if source_end {
routed.points.first()
} else {
routed.points.last()
})
.copied() else {
continue;
};
let inward = if source_end {
routed.points.iter().find(|p| **p != tip)
} else {
routed.points.iter().rev().find(|p| **p != tip)
};
let Some(inward) = inward.copied() else {
continue;
};
let full = crate::data::association_end_label(relation, source_end).unwrap_or_default();
let text = if let Some((role, multiplicity)) = full.rsplit_once(" [") {
let role = if role.chars().count() > 32 {
role.chars()
.take(31)
.chain(std::iter::once('…'))
.collect::<String>()
} else {
role.to_owned()
};
format!("{role} [{multiplicity}")
} else {
full
};
let size = Size {
width: text.chars().count() as f32 * 7.0 + 12.0,
height: 22.0,
};
let dx = inward.x - tip.x;
let dy = inward.y - tip.y;
let length = dx.hypot(dy);
let direction = pos(dx / length, dy / length);
// One normal sign puts opposite endpoints of a straight edge on
// opposite sides because their inward tangent directions are reversed.
let along =
direction.x.abs() * size.width / 2.0 + direction.y.abs() * size.height / 2.0 + 12.0;
let normal =
direction.y.abs() * size.width / 2.0 + direction.x.abs() * size.height / 2.0 + 8.0;
let mut center = pos(
tip.x + direction.x * along - direction.y * normal,
tip.y + direction.y * along + direction.x * normal,
);
if let Some(other) = ends.first()
&& (center.x - other.label.center.x).abs()
< (size.width + other.label.bounds.size.width) / 2.0 + 6.0
&& (center.y - other.label.center.y).abs()
< (size.height + other.label.bounds.size.height) / 2.0 + 6.0
{
// Same-side anchors on a self-loop can still be close. Separate
// their complete bounds along this end's normal in one step.
let perpendicular = pos(-direction.y, direction.x);
let distance = (center.x - other.label.center.x) * perpendicular.x
+ (center.y - other.label.center.y) * perpendicular.y;
let clearance = (perpendicular.x.abs() * (size.width + other.label.bounds.size.width)
+ perpendicular.y.abs() * (size.height + other.label.bounds.size.height))
/ 2.0
+ 8.0;
let shift = if distance < 0.0 {
-clearance - distance
} else {
clearance - distance
};
center.x += perpendicular.x * shift;
center.y += perpendicular.y * shift;
}
ends.push(AssociationEndGeometry {
source_end,
label: EdgeLabelGeometry {
text,
fraction: if source_end { 0.0 } else { 1.0 },
anchor: tip,
center,
bounds: RoutingBounds {
position: pos(center.x - size.width / 2.0, center.y - size.height / 2.0),
size,
},
},
tip,
inward,
navigable,
});
}
ends
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning {
PinInsideObstacle { pin: usize, obstacle: Id },
@@ -466,6 +582,14 @@ fn route_diagram_impl(
// never changes paths or authored metadata. Previews retain other cached
// labels and avoid the paths actually shown during the gesture.
let mut previous_labels = Vec::new();
for routed in edges.values() {
if let Some(relation) = project.relations.get(&routed.id) {
for end in association_end_geometry(relation, routed) {
bounds.rect(end.label.bounds.position, end.label.bounds.size);
previous_labels.push(end.label.bounds);
}
}
}
for edge in ordered {
let Some(routed) = edges.get_mut(&edge.id) else {
continue;
@@ -579,7 +703,7 @@ fn route_edge(
let mut candidates = Vec::new();
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
// A real self-transition must leave and return at distinct anchors.
// A real self-relation must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
@@ -1107,7 +1231,8 @@ fn label_text(project: &Project, edge: Id) -> String {
let text = match relation.kind() {
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange => relation.name.as_str(),
| RelationKind::BoundaryExchange
| RelationKind::DataAssociation => relation.name.as_str(),
RelationKind::CapabilityInclude => "«include»",
RelationKind::CapabilityExtend => "«extend»",
RelationKind::CapabilityGeneralization => "specializes",
+17
View File
@@ -114,6 +114,7 @@ pub struct PreparedCommand {
struct IdentityProvenance {
original: BTreeSet<Id>,
current_to_original: BTreeMap<Id, Id>,
explicit_replacement: bool,
}
impl IdentityProvenance {
@@ -123,10 +124,12 @@ impl IdentityProvenance {
Self {
original,
current_to_original,
explicit_replacement: false,
}
}
fn replace(&mut self, project: &Project) {
self.explicit_replacement = true;
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
self.current_to_original = self
.original
@@ -180,6 +183,13 @@ pub fn prepare_command(
)?;
crate::references::finish_deletions(&mut candidate, &pending_deletions);
provenance.retain_present(&candidate);
if !provenance.explicit_replacement {
crate::data::prune_removed_inheritance(
project,
&mut candidate,
&provenance.current_to_original,
);
}
validate_candidate(&candidate)?;
Ok(PreparedCommand {
project: candidate,
@@ -287,6 +297,13 @@ pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project,
)?;
}
crate::references::finish_deletions(&mut candidate, &pending_deletions);
if !provenance.explicit_replacement {
crate::data::prune_removed_inheritance(
project,
&mut candidate,
&provenance.current_to_original,
);
}
validate_candidate(&candidate)?;
Ok(candidate)
}
+20
View File
@@ -251,6 +251,8 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
}
(
RelationBody::Allocation { .. }
| RelationBody::InterfaceItemAllocation { .. }
| RelationBody::DataAssociation { .. }
| RelationBody::ExchangeAllocation { .. }
| RelationBody::CapabilityInvolvement { .. }
| RelationBody::CapabilityInclude { .. }
@@ -421,6 +423,24 @@ pub fn plan_transition(
}
if let Some(e) = project.elements.get(&id) {
selected.extend(crate::references::element_references(e));
// Allocations are owned by interfaces. Independent data
// associations travel only when both endpoint types are
// already required; they do not expand the dictionary.
if matches!(e.kind(), Kind::Interface | Kind::DataType) {
let contracts: Vec<_> = project
.relations
.values()
.filter_map(|r| {
((r.kind() == RelationKind::InterfaceItemAllocation
&& r.source() == id)
|| (r.kind() == RelationKind::DataAssociation
&& selected.contains(&r.source())
&& selected.contains(&r.target())))
.then_some(r.id)
})
.collect();
selected.extend(contracts);
}
// Contextual links are owned by the process/chain shared by
// their endpoint involvements, independently of list order.
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
+120 -94
View File
@@ -388,68 +388,10 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
if let Some(shared) = e.shared() {
match shared {
SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals },
} => {
let mut seen = BTreeSet::new();
for literal in literals {
text(&mut out, e.id, "literals", literal);
if !seen.insert(literal) {
error(
&mut out,
e.id,
"duplicate_literal",
"literals",
"Enumeration literals must be unique.",
);
}
}
}
SharedBody::DataField {
type_ref,
minimum,
maximum,
unit,
} => {
if maximum.is_some_and(|max| max < *minimum) {
error(
&mut out,
e.id,
"invalid_multiplicity",
"maximum",
"Maximum multiplicity must be at least minimum, or unbounded.",
);
}
text(&mut out, e.id, "unit", unit);
if let TypeRef::Named(id) = type_ref {
expect_element(p, &mut out, e.id, *id, "type_ref", |k| k == Kind::DataType);
}
if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id))
&& parent.kind() == Kind::DataType
&& !matches!(
parent.shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Record
})
)
{
error(
&mut out,
e.id,
"field_owner",
"parent",
"Fields under a data type require a Record type.",
);
}
}
SharedBody::Interface { items } => {
check_ids(&mut out, e.id, "items", items);
for id in items {
expect_element(p, &mut out, e.id, *id, "items", |k| {
k == Kind::ExchangeItem
});
}
}
SharedBody::DataType { .. }
| SharedBody::DataField { .. }
| SharedBody::Interface { .. }
| SharedBody::ExchangeItem { .. } => crate::data::validate_element(p, e, &mut out),
SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out),
SharedBody::Scenario {
viewpoint,
@@ -643,9 +585,11 @@ pub fn validate(p: &Project) -> Vec<Issue> {
text(&mut out, r.id, "name", &r.name);
text(&mut out, r.id, "description", &r.description);
validate_relation(p, r, &mut out);
crate::data::validate_relation(p, r, &mut out);
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
validate_relation_context(p, r, &mut out);
}
crate::data::validate_record_cycles(p, &mut out);
for (key, d) in &p.diagrams {
if *key != d.id {
error(
@@ -1436,23 +1380,10 @@ fn enclosing_loops(p: &Project, occurrence: Id) -> BTreeSet<Id> {
}
/// Fields carried by an exchange, including fields of nested named records.
pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet<Id> {
let mut pending = relation.body.items().to_vec();
let mut seen = BTreeSet::new();
let mut fields = BTreeSet::new();
while let Some(owner) = pending.pop() {
if !seen.insert(owner) {
continue;
}
for field in p.children(owner) {
if let Some(SharedBody::DataField { type_ref, .. }) = field.shared() {
fields.insert(field.id);
if let TypeRef::Named(id) = type_ref {
pending.push(*id);
}
}
}
}
fields
crate::data::carried_field_paths(p, relation)
.into_iter()
.map(|f| f.field)
.collect()
}
fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool {
match body {
@@ -1683,36 +1614,125 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
}
}
}
let allowed = exchange
.and_then(|id| p.relations.get(&id))
.map(|r| carried_fields(p, r));
let relation = exchange.and_then(|id| p.relations.get(&id));
if message_kind == &MessageKind::Call
&& relation.is_some_and(|r| {
!r.body
.items()
.iter()
.any(|id| crate::data::item_mechanism(p, *id) == Some(ExchangeMechanism::Operation))
})
{
warning(
out,
e.id,
"call_without_operation",
"exchange",
"This generic call carries no Operation item; define an operation contract when appropriate.",
);
}
let mut fields = BTreeSet::new();
for value in values {
expect_element(p, out, e.id, value.field, "values.field", |kind| {
kind == Kind::DataField
});
for hop in &value.path {
expect_element(p, out, e.id, *hop, "values.path", |kind| {
kind == Kind::DataField
});
}
text(out, e.id, "values.expression", &value.expression);
if !fields.insert(value.field) {
if !fields.insert((&value.path, value.field)) {
error(
out,
e.id,
"duplicate_message_value",
"values",
"Each carried field has at most one contextual value annotation in a message.",
"Each qualified carried field has at most one contextual value annotation.",
);
}
if allowed
.as_ref()
.is_some_and(|allowed| !allowed.contains(&value.field))
{
if value.path.len() > crate::data::MAX_VALUE_PATH_DEPTH {
error(
out,
e.id,
"message_value_field",
"values.field",
"Value annotations must reference fields carried by this message's exchange, including nested record fields.",
"message_value_path_limit",
"values.path",
"The value path exceeds the bounded authoring depth.",
);
}
if let Some(r) = relation {
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
let permitted = match message_kind {
MessageKind::Call => matches!(
path.direction,
ParameterDirection::Unspecified
| ParameterDirection::In
| ParameterDirection::InOut
),
MessageKind::Reply => matches!(
path.direction,
ParameterDirection::Unspecified
| ParameterDirection::Out
| ParameterDirection::InOut
| ParameterDirection::Return
),
_ => true,
};
if !permitted {
error(
out,
e.id,
"message_parameter_phase",
"values.path",
"Call values address In/InOut parameters; replies address Out/InOut/Return parameters.",
);
}
if path.direction == ParameterDirection::Unspecified {
warning(
out,
e.id,
"message_parameter_direction",
"values.path",
"The containing operation parameter has no direction yet.",
);
}
}
} else {
let legacy = value
.path
.is_empty()
.then(|| crate::data::resolve_legacy_field_path(p, r, value.field).ok())
.flatten();
if matches!(
legacy,
Some(
crate::data::LegacyFieldPathResolution::Unique(_)
| crate::data::LegacyFieldPathResolution::Ambiguous
)
) {
warning(
out,
e.id,
if legacy == Some(crate::data::LegacyFieldPathResolution::Ambiguous) {
"message_value_ambiguous_path"
} else {
"message_value_missing_path"
},
"values.path",
"This legacy nested field annotation needs an explicit containing parameter path; its expression is preserved.",
);
} else {
error(
out,
e.id,
"message_value_field",
"values.path",
"The qualified field must be carried by the exchange, following Value record fields only.",
);
}
}
}
if value.expression.trim().is_empty() {
warning(
out,
@@ -2130,7 +2150,11 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
};
if a.layer() != b.layer()
|| !r.kind().permits(a.kind(), b.kind())
|| (a.id == b.id && r.kind() != RelationKind::StateTransition)
|| (a.id == b.id
&& !matches!(
r.kind(),
RelationKind::StateTransition | RelationKind::DataAssociation
))
{
error(
out,
@@ -2381,8 +2405,10 @@ pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option<Id
owner.layer() == layer
&& parent_permits(owner.kind(), child_kind)
&& match owner.shared() {
Some(SharedBody::DataType { definition }) if child_kind == Kind::DataField => {
*definition == DataTypeDefinition::Record
Some(SharedBody::DataType { definition, .. })
if child_kind == Kind::DataField =>
{
matches!(definition, DataTypeDefinition::Record { .. })
}
Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => {
matches!(
File diff suppressed because it is too large Load Diff
+18 -15
View File
@@ -447,11 +447,12 @@ fn standard_interface_roles_do_not_follow_message_direction_and_kind_mixing_is_r
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] };
p.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
let requirement = p.relate(
RelationKind::InterfaceRequirement,
"Caller requires service",
@@ -499,11 +500,12 @@ fn legacy_split_preserves_original_identity_prose_interface_artifact_constraint_
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.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
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,
@@ -659,11 +661,12 @@ 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.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
+4 -1
View File
@@ -94,7 +94,9 @@ fn fixture(layer: Layer) -> Fixture {
.add_element(Kind::DataType, layer, "Request record", None)
.unwrap();
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record,
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
let field = p
.add_element(Kind::DataField, layer, "Requested level", Some(record))
@@ -160,6 +162,7 @@ fn fixture(layer: Layer) -> Fixture {
}) = p.elements.get_mut(&call).unwrap().shared_mut()
{
values.push(MessageValue {
path: vec![payload],
field,
expression: "requested_level".into(),
});
+47 -9
View File
@@ -328,11 +328,12 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None);
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None);
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
p.relate(
RelationKind::InterfaceItemAllocation,
"Interface payload",
interface,
item,
);
let port = |p: &mut Project, name: &str, parent, direction| {
let id = Id::new_v4();
let contract = if p.elements[&parent].kind() == Kind::Function {
@@ -740,6 +741,9 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
minimum: 0,
maximum: None,
unit: "items".into(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
@@ -804,8 +808,40 @@ fn every_demo_identity_can_be_remapped_and_deleted_without_dangling_references()
"Reidentification must preserve all values and order"
);
if id != p.id {
let deleted = execute_batch(&p, &[Command::Delete(id)])
.unwrap_or_else(|err| panic!("Delete {id}: {err}"));
let deleted = if p
.relations
.get(&id)
.is_some_and(|r| r.kind() == RelationKind::InterfaceItemAllocation)
{
// An explicitly empty interface cannot silently accept the
// still-carried payload. Removing its definition needs a
// reviewed edit of that dependent contract in the same batch.
let mut session = Session::new(p.clone());
assert!(
session
.execute(Command::Delete(id))
.unwrap_err()
.0
.contains("port_interface_items")
);
assert_eq!(session.project(), &p);
assert_eq!(session.revision(), 0);
let item = p.relations[&id].target();
let mut commands = vec![Command::Delete(id)];
commands.extend(
p.relations
.values()
.filter(|r| {
r.kind() == RelationKind::BoundaryExchange
&& r.body.items().contains(&item)
})
.map(|r| Command::Delete(r.id)),
);
execute_batch(&p, &commands).unwrap()
} else {
execute_batch(&p, &[Command::Delete(id)])
.unwrap_or_else(|err| panic!("Delete {id}: {err}"))
};
accepted(&deleted);
}
}
@@ -890,13 +926,15 @@ fn demo_has_both_layers_complete_contracts_and_no_checks() {
assert!(p.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material
kind: ExchangeItemKind::Material,
..
})
)));
assert!(p.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy
kind: ExchangeItemKind::Energy,
..
})
)));
}