Checkpoint source 7 workflows and in-progress diagram authoring redesign
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s

Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
This commit is contained in:
Codex
2026-09-10 10:06:55 -07:00
parent 4b6b5fc197
commit e2ecfea69b
162 changed files with 17595 additions and 926 deletions
+1
View File
@@ -15,6 +15,7 @@ fn occurrence(
let id = add(p, Kind::Occurrence, layer, name, Some(owner));
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: Some(exchange),
sender,
receiver,
+32 -7
View File
@@ -84,7 +84,20 @@ impl ParseLimits {
.elements
.values()
.map(|e| e.steps().map_or(0, Vec::len))
.sum();
.sum::<usize>()
.saturating_add(
project
.relations
.values()
.map(|r| match &r.body {
RelationBody::ContextualLink { source, target, .. } => source
.references
.len()
.saturating_add(target.references.len()),
_ => 0,
})
.sum::<usize>(),
);
self.check_counts(graph, records, steps)
}
}
@@ -343,6 +356,10 @@ fn reference_field(key: &str) -> bool {
| "local"
| "field"
| "path"
| "references"
| "occurrence"
| "allocation"
| "work"
| "supertype"
| "sender"
| "receiver"
@@ -1386,9 +1403,10 @@ impl Parser<'_> {
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
let mut header = if entry {
self.take("arcadia")?;
if !matches!(self.text(), "3" | "4" | "5" | "6") {
return Err(self
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;', 'arcadia 5;' or 'arcadia 6;'."));
if !matches!(self.text(), "3" | "4" | "5" | "6" | "7") {
return Err(
self.error("Unsupported schema version: expected arcadia 3, 4, 5, 6 or 7.")
);
}
let schema_version = self.text().parse().expect("checked source version");
self.cursor += 1;
@@ -1606,6 +1624,7 @@ pub(crate) fn resolve_declarations(
};
match d.kind {
DeclKind::Element(kind, layer) => {
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) {
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
}
@@ -1683,6 +1702,7 @@ pub(crate) fn resolve_declarations(
);
}
DeclKind::Relation(kind) => {
if project.schema_version < 7 && kind == RelationKind::ContextualLink { return Err(conversion_error("ContextualLink requires schema/source 7.".into())); }
if project.schema_version < 6 && !crate::persistence::pre6_relation_kind(kind) {
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
}
@@ -1691,7 +1711,9 @@ pub(crate) fn resolve_declarations(
}
let description =
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
let required: &[&str] = if kind == RelationKind::ContextualLink {
&["owner", "source", "target", "purpose"]
} else if kind == RelationKind::FunctionalExchange {
&["source", "target", "source_port", "target_port"]
} else {
&["source", "target"]
@@ -1722,6 +1744,7 @@ pub(crate) fn resolve_declarations(
);
}
DeclKind::Diagram => {
if project.schema_version < 7 { crate::persistence::reject_source7_fields(&Value::Object(fields.clone())).map_err(&conversion_error)?; }
if project.schema_version < 6 && fields.contains_key("view_options") {
return Err(conversion_error("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
@@ -1852,11 +1875,13 @@ pub(crate) fn resolve_declarations(
}
return Ok(migrated);
}
if project.schema_version == 4 || project.schema_version == 5 {
if matches!(project.schema_version, 4..=6) {
return (if project.schema_version == 4 {
crate::persistence::v4::finish(project, limits)
} else {
} else if project.schema_version == 5 {
crate::persistence::migrate_v5(project, limits)
} else {
crate::persistence::migrate_v6(project, limits)
})
.map_err(|error| {
(
@@ -109,6 +109,10 @@ pub(super) fn resolve_baseline(
continue;
}
let json = raw(value)?;
if version < 7 {
crate::persistence::reject_source7_fields(&json)
.map_err(|cause| error(value, cause))?;
}
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
if version < 6 {
+2
View File
@@ -2,6 +2,7 @@
pub mod data;
pub mod language;
pub mod model;
pub mod operations;
pub mod persistence;
pub mod ports;
pub mod projection;
@@ -11,6 +12,7 @@ pub mod semantic;
pub mod session;
pub mod source_files;
pub mod source_project;
pub mod topology_context;
pub mod transitions;
pub mod validation;
pub mod view_options;
+69 -3
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 6;
pub const SCHEMA_VERSION: u32 = 7;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -523,6 +523,28 @@ pub struct MessageValue {
pub field: Id,
pub expression: String,
}
/// Explicit operation intent. A missing allocation remains an unfinished operation,
/// whereas an absent OperationUse retains ordinary channel-message semantics.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OperationUse {
pub allocation: Option<Id>,
}
/// A contribution in a caller instance. Paths contain reference occurrence IDs,
/// never sequence definitions; repeated references therefore remain distinct.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OccurrenceEndpoint {
pub references: Vec<Id>,
pub occurrence: Id,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ContextualLinkPurpose {
Precedence { guard: String },
Exchange { exchange: Id },
CommonWork { work: Id },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum OccurrenceBody {
@@ -536,6 +558,8 @@ pub enum OccurrenceBody {
reply_to: Option<Id>,
#[serde(default)]
values: Vec<MessageValue>,
#[serde(default, skip_serializing_if = "Option::is_none")]
operation: Option<OperationUse>,
},
Action {
action: Option<Id>,
@@ -797,6 +821,7 @@ impl ElementBody {
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
@@ -863,6 +888,7 @@ pub enum RelationKind {
CapabilityGeneralization,
MissionInvolvement,
SequenceLink,
ContextualLink,
StateTransition,
Availability,
ExchangeAllocation,
@@ -871,7 +897,7 @@ pub enum RelationKind {
StateAction,
}
impl RelationKind {
pub const ALL: [Self; 24] = [
pub const ALL: [Self; 25] = [
Self::InterfaceItemAllocation,
Self::DataAssociation,
Self::Allocation,
@@ -890,6 +916,7 @@ impl RelationKind {
Self::CapabilityGeneralization,
Self::MissionInvolvement,
Self::SequenceLink,
Self::ContextualLink,
Self::StateTransition,
Self::Availability,
Self::ExchangeAllocation,
@@ -937,7 +964,9 @@ impl RelationKind {
(Kind::Entity | Kind::Actor, Kind::OperationalMission)
| (Kind::System | Kind::SystemActor, Kind::Mission)
),
Self::SequenceLink => from == Kind::Occurrence && to == Kind::Occurrence,
Self::SequenceLink | Self::ContextualLink => {
from == Kind::Occurrence && to == Kind::Occurrence
}
Self::StateTransition => from.is_state() && to.is_state(),
Self::ExchangeAllocation => false,
Self::InterfaceProvision | Self::InterfaceRequirement => {
@@ -980,6 +1009,9 @@ impl RelationKind {
Self::CapabilityGeneralization => "Specific capability → general capability",
Self::MissionInvolvement => "Participant → mission in the same perspective",
Self::SequenceLink => "Preceding → following contribution in the same process or chain",
Self::ContextualLink => {
"Contributions addressed in an explicit process or chain caller"
}
Self::StateTransition => "State → state in one machine",
Self::ExchangeAllocation => {
"Communication means / boundary exchange → operational interaction / functional exchange"
@@ -1012,6 +1044,7 @@ impl fmt::Display for RelationKind {
Self::CapabilityGeneralization => "Capability generalization",
Self::MissionInvolvement => "Mission involvement",
Self::SequenceLink => "Sequence link",
Self::ContextualLink => "Contextual link",
Self::StateTransition => "State transition",
Self::ExchangeAllocation => "Exchange allocation",
Self::InterfaceProvision => "Interface provision",
@@ -1126,6 +1159,12 @@ pub enum RelationBody {
target: Id,
guard: String,
},
ContextualLink {
owner: Id,
source: OccurrenceEndpoint,
target: OccurrenceEndpoint,
purpose: ContextualLinkPurpose,
},
StateTransition {
source: Id,
target: Id,
@@ -1162,6 +1201,13 @@ pub enum RelationBody {
},
}
impl Relation {
/// Caller ownership cannot be inferred from a contextual link's leaf IDs.
pub fn contextual_owner(&self) -> Option<Id> {
match self.body {
RelationBody::ContextualLink { owner, .. } => Some(owner),
_ => None,
}
}
pub fn kind(&self) -> RelationKind {
self.body.kind()
}
@@ -1193,6 +1239,7 @@ impl RelationBody {
Self::CapabilityGeneralization { .. } => RelationKind::CapabilityGeneralization,
Self::MissionInvolvement { .. } => RelationKind::MissionInvolvement,
Self::SequenceLink { .. } => RelationKind::SequenceLink,
Self::ContextualLink { .. } => RelationKind::ContextualLink,
Self::StateTransition { .. } => RelationKind::StateTransition,
Self::ExchangeAllocation { .. } => RelationKind::ExchangeAllocation,
Self::InterfaceProvision { .. } => RelationKind::InterfaceProvision,
@@ -1203,6 +1250,7 @@ impl RelationBody {
}
pub fn endpoints(&self) -> (Id, Id) {
match self {
Self::ContextualLink { source, target, .. } => (source.occurrence, target.occurrence),
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
@@ -1231,6 +1279,10 @@ impl RelationBody {
}
pub fn set_endpoints(&mut self, a: Id, b: Id) {
match self {
Self::ContextualLink { source, target, .. } => {
source.occurrence = a;
target.occurrence = b;
}
Self::InterfaceItemAllocation { source, target, .. }
| Self::DataAssociation { source, target, .. }
| Self::Allocation { source, target }
@@ -1337,6 +1389,20 @@ impl RelationBody {
target,
guard: String::new(),
},
RelationKind::ContextualLink => Self::ContextualLink {
owner: Id::nil(),
source: OccurrenceEndpoint {
references: vec![],
occurrence: source,
},
target: OccurrenceEndpoint {
references: vec![],
occurrence: target,
},
purpose: ContextualLinkPurpose::Precedence {
guard: String::new(),
},
},
RelationKind::StateTransition => Self::StateTransition {
source,
target,
+345
View File
@@ -0,0 +1,345 @@
//! One operation resolver for admission, forms and scenario labels.
use crate::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedOperation {
/// The actual Call/Asynchronous declaration, also when resolving its Reply.
pub invocation: Id,
pub allocation: Id,
pub interface: Id,
pub item: Id,
pub caller_port: Id,
pub provider_port: Id,
pub transmission: TransmissionProtocol,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OperationResolution {
Ordinary,
Unresolved { invocation: Id, reason: String },
Selected(ResolvedOperation),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct OperationError {
pub code: &'static str,
pub message: String,
}
fn error(code: &'static str, message: &str) -> OperationError {
OperationError {
code,
message: message.into(),
}
}
pub fn resolve(p: &Project, occurrence: Id) -> Result<OperationResolution, OperationError> {
let e = p
.elements
.get(&occurrence)
.ok_or_else(|| error("operation_context", "The message no longer exists."))?;
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
operation,
message_kind,
reply_to,
..
},
..
}) = e.shared()
else {
return Ok(OperationResolution::Ordinary);
};
let invocation = if *message_kind == MessageKind::Reply {
if operation.is_some() {
return Err(error(
"reply_operation",
"A Reply derives its operation from its exact Call; it cannot store a second selection.",
));
}
let Some(call) = reply_to else {
return Ok(OperationResolution::Ordinary);
};
if !matches!(
p.elements.get(call).and_then(Element::shared),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
..
},
..
})
) {
return Ok(OperationResolution::Ordinary);
}
*call
} else {
occurrence
};
let Some(SharedBody::Occurrence {
body:
body @ OccurrenceBody::Exchange {
operation: Some(operation),
message_kind,
exchange,
sender,
receiver,
..
},
..
}) = p.elements.get(&invocation).and_then(Element::shared)
else {
return Ok(OperationResolution::Ordinary);
};
if p.layer_of(invocation) != Some(Layer::Sa)
|| !matches!(message_kind, MessageKind::Call | MessageKind::Asynchronous)
|| validation::ancestor_of_kind(p, invocation, Kind::Scenario)
.and_then(|id| p.elements.get(&id))
.and_then(Element::shared)
.is_none_or(|body| {
!matches!(
body,
SharedBody::Scenario {
viewpoint: ScenarioViewpoint::BoundaryInterface,
..
}
)
})
{
return Err(error(
"operation_context",
"Operation use belongs to a Call or Asynchronous message in an SA BoundaryInterface scenario.",
));
}
let unresolved = |reason: &str| {
Ok(OperationResolution::Unresolved {
invocation,
reason: reason.into(),
})
};
if exchange
.and_then(|id| p.relations.get(&id))
.is_some_and(|r| r.kind() != RelationKind::BoundaryExchange)
{
return Err(error(
"operation_channel",
"Operation use requires an explicit boundary exchange channel.",
));
}
let Some(allocation) = operation.allocation else {
return unresolved(
"Choose the interface operation allocation; existing value expressions are preserved.",
);
};
let Some(relation) = p.relations.get(&allocation) else {
return Err(error(
"operation_allocation",
"The selected interface operation allocation no longer exists.",
));
};
let RelationBody::InterfaceItemAllocation {
source: interface,
target: item,
transmission,
..
} = relation.body
else {
return Err(error(
"operation_allocation",
"Choose an InterfaceItemAllocation, not an item or another relationship.",
));
};
if p.layer_of(allocation) != Some(Layer::Sa)
|| !matches!(
p.elements.get(&item).and_then(Element::shared),
Some(SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Operation,
..
})
)
{
return Err(error(
"operation_allocation",
"The selected SA allocation must declare an Information item with Operation mechanism.",
));
}
let Some(channel) = exchange.and_then(|id| p.relations.get(&id)) else {
return Err(error(
"operation_channel",
"A selected operation requires an existing boundary exchange carrying its item; clear the allocation to retain unresolved intent.",
));
};
if channel.kind() != RelationKind::BoundaryExchange
|| !channel
.body
.items()
.iter()
.any(|carried| data::item_satisfies(p, *carried, item))
{
return Err(error(
"operation_channel",
"The boundary exchange must carry the selected operation item or an admitted subtype.",
));
}
if sender.is_none() || receiver.is_none() {
return Err(error(
"operation_lifelines",
"A selected operation requires actual caller and provider lifelines; clear the allocation to retain unresolved intent.",
));
}
let (caller_port, provider_port) = match validation::resolve_message_endpoints(p, body) {
validation::MessageEndpointResolution::Resolved { source, target } => (source, target),
validation::MessageEndpointResolution::Conflict => {
return Err(error(
"operation_direction",
"The selected lifelines conflict with the boundary channel's actual caller/provider direction.",
));
}
_ => {
return Err(error(
"operation_direction",
"A selected operation requires a proven actual caller/provider channel direction; clear the allocation to retain unresolved intent.",
));
}
};
for port in [caller_port, provider_port] {
if !matches!(
p.elements.get(&port).and_then(ports::port_contract),
Some(PortContract::Component {
kind: ComponentPortKind::Standard,
..
})
) {
return Err(error(
"operation_ports",
"Operation proof requires concrete Standard component ports.",
));
}
}
let exposes = |interface| {
data::effective_interface_allocations(p, interface)
.iter()
.any(|r| r.id == allocation)
};
let providers: Vec<_> = p
.relations
.values()
.filter(|r| {
r.kind() == RelationKind::InterfaceProvision
&& r.source() == provider_port
&& exposes(r.target())
})
.map(Relation::target)
.collect();
let valid = p.relations.values().any(|r| {
r.kind() == RelationKind::InterfaceRequirement
&& r.source() == caller_port
&& exposes(r.target())
&& providers
.iter()
.any(|provided| data::interface_satisfies(p, *provided, r.target()))
});
if !valid {
return Err(error(
"operation_roles",
"The actual receiver port must provide, and sender port require, compatible interfaces exposing this exact allocation.",
));
}
if !matches!(
(message_kind, transmission),
(_, TransmissionProtocol::Unspecified)
| (MessageKind::Call, TransmissionProtocol::Synchronous)
| (
MessageKind::Asynchronous,
TransmissionProtocol::Asynchronous
)
) {
return Err(error(
"operation_transmission",
"Calls require synchronous transmission; asynchronous operation messages require asynchronous transmission.",
));
}
let selected = ResolvedOperation {
invocation,
allocation,
interface,
item,
caller_port,
provider_port,
transmission,
};
if occurrence != invocation {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: reply_exchange,
..
},
..
}) = e.shared()
else {
unreachable!()
};
if let Some(response) = reply_exchange.and_then(|id| p.relations.get(&id))
&& response.id != channel.id
&& !response
.body
.items()
.iter()
.any(|carried| data::item_satisfies(p, *carried, item))
{
return Err(error(
"operation_reply_channel",
"A separate reply channel must carry the Call's selected operation item or an admitted subtype, including parameterless returns.",
));
}
}
Ok(OperationResolution::Selected(selected))
}
/// Valid selections under the proposed message context, without mutating the model.
pub fn eligible_allocations(p: &Project, occurrence: Id) -> Vec<Id> {
let mut candidate = p.clone();
let ids: Vec<_> = p
.relations
.values()
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
.map(|r| r.id)
.collect();
ids.into_iter()
.filter(|id| {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = candidate
.elements
.get_mut(&occurrence)
.and_then(Element::shared_mut)
else {
return false;
};
*operation = Some(OperationUse {
allocation: Some(*id),
});
matches!(
resolve(&candidate, occurrence),
Ok(OperationResolution::Selected(_))
)
})
.collect()
}
/// Reuse the existing bounded field-path engine with exactly one selected item.
pub fn signature_relation(operation: &ResolvedOperation) -> Relation {
Relation {
id: operation.allocation,
alias: String::new(),
name: String::new(),
description: String::new(),
body: RelationBody::Interaction {
source: operation.caller_port,
target: operation.provider_port,
items: vec![operation.item],
},
}
}
+74 -2
View File
@@ -139,6 +139,13 @@ pub fn decode_with_report_limited(
}
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
let header: serde_json::Value = serde_json::from_slice(bytes)?;
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 6)
{
reject_source7_fields(&header).map_err(FileError::Invalid)?;
}
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
@@ -182,13 +189,14 @@ pub fn decode_with_report_limited(
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
Some(6) => DecodeResult {
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
Some(7) => DecodeResult {
project: serde_json::from_slice(bytes)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5 or 6.".into(),
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".into(),
));
}
}
@@ -357,6 +365,70 @@ pub(crate) fn migrate_v5(
})
}
/// Reject explicit new fields before serde defaults can erase their presence.
/// This is also applied to source's resolved structured values.
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
match value {
serde_json::Value::Object(fields) => {
if fields.contains_key("ContextualLink") {
return Err("ContextualLink requires schema/source 7.".into());
}
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
{
return Err("ContextualLink view selectors require schema/source 7.".into());
}
if fields.keys().any(|key| {
matches!(
key.as_str(),
"Operation"
| "ContextOwner"
| "ContextSource"
| "ContextTarget"
| "ContextPurpose"
)
}) {
return Err(
"Operation/contextual-link semantic facets require schema/source 7.".into(),
);
}
if fields
.get("Exchange")
.and_then(serde_json::Value::as_object)
.is_some_and(|body| body.contains_key("operation"))
{
return Err(
"Exchange.operation requires schema/source 7, including explicit null.".into(),
);
}
for nested in fields.values() {
reject_source7_fields(nested)?;
}
}
serde_json::Value::Array(values) => {
for nested in values {
reject_source7_fields(nested)?;
}
}
_ => {}
}
Ok(())
}
pub(crate) fn migrate_v6(
mut project: Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if project.schema_version != 6 {
return Err(FileError::Invalid("Expected schema_version 6.".into()));
}
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
Ok(DecodeResult {
project,
migration: Some(MigrationReport::data_upgrade(6)),
})
}
pub(crate) fn migrate_v1_with_report_limited(
old: v1::Project,
limits: crate::language::ParseLimits,
+29
View File
@@ -87,8 +87,15 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
receiver,
reply_to,
values,
operation,
..
} => {
if let Some(id) = operation
.as_mut()
.and_then(|operation| operation.allocation.as_mut())
{
visit(id);
}
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
visit(id);
}
@@ -150,6 +157,22 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
visit(&mut target);
r.body.set_endpoints(source, target);
match &mut r.body {
RelationBody::ContextualLink {
owner,
source,
target,
purpose,
} => {
visit(owner);
for id in source.references.iter_mut().chain(&mut target.references) {
visit(id);
}
match purpose {
ContextualLinkPurpose::Exchange { exchange } => visit(exchange),
ContextualLinkPurpose::CommonWork { work } => visit(work),
ContextualLinkPurpose::Precedence { .. } => {}
}
}
RelationBody::StateTransition {
triggers, effects, ..
} => {
@@ -274,6 +297,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
});
if removed.contains(&r.source())
|| removed.contains(&r.target())
|| (r.kind() == RelationKind::ContextualLink
&& relation_references(r).iter().any(|id| removed.contains(id)))
|| port_removed
|| work_allocation_removed
{
@@ -444,8 +469,12 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
receiver,
reply_to,
values,
operation,
..
} => {
if let Some(operation) = operation {
clear(&mut operation.allocation);
}
clear(exchange);
clear(sender);
clear(receiver);
+39 -2
View File
@@ -81,6 +81,35 @@ impl Own {
}
let kind = enum_tag(value)?.to_string();
prefix.push(kind.clone());
let mut whole = whole;
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Call | MessageKind::Asynchronous,
..
},
..
}) = project.elements.get(&id).and_then(Element::shared)
&& project.layer_of(id) == Some(Layer::Sa)
&& crate::validation::ancestor_of_kind(project, id, Kind::Scenario)
.and_then(|id| project.elements.get(&id))
.and_then(Element::shared)
.is_some_and(|body| {
matches!(
body,
SharedBody::Scenario {
viewpoint: ScenarioViewpoint::BoundaryInterface,
..
}
)
})
{
let mut operation_path = prefix.clone();
operation_path.extend(["body".into(), "Exchange".into()]);
if let Some(body) = at_mut(&mut whole, &operation_path).and_then(Value::as_object_mut) {
body.entry("operation").or_insert(Value::Null);
}
}
Some(Self {
whole,
prefix,
@@ -163,6 +192,9 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
F::Occurrence(OccurrenceFacet::ReplyTo) => {
("Occurrence", &["body", "Exchange", "reply_to"])
}
F::Occurrence(OccurrenceFacet::Operation) => {
("Occurrence", &["body", "Exchange", "operation"])
}
F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]),
F::Occurrence(OccurrenceFacet::ActionLifeline) => {
("Occurrence", &["body", "Action", "lifeline"])
@@ -192,8 +224,12 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
F::Category(CategoryFacet::Family) => ("ExchangeCategory", &["family"]),
F::Relation(RelationFacet::Source) => ("*", &["source"]),
F::Relation(RelationFacet::Target) => ("*", &["target"]),
F::Relation(RelationFacet::Source) if own.kind != "ContextualLink" => ("*", &["source"]),
F::Relation(RelationFacet::Target) if own.kind != "ContextualLink" => ("*", &["target"]),
F::Relation(RelationFacet::ContextOwner) => ("ContextualLink", &["owner"]),
F::Relation(RelationFacet::ContextSource) => ("ContextualLink", &["source"]),
F::Relation(RelationFacet::ContextTarget) => ("ContextualLink", &["target"]),
F::Relation(RelationFacet::ContextPurpose) => ("ContextualLink", &["purpose"]),
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]),
F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]),
@@ -926,6 +962,7 @@ mod coverage {
] {
add(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: Some(id),
sender: Some(id),
receiver: Some(id),
+25
View File
@@ -171,6 +171,31 @@ impl Historical for LifelineBinding {
w.at("local", |w| self.local.walk(w));
}
}
impl Historical for OperationUse {
fn walk(&mut self, w: &mut Walk<'_>) {
w.at("allocation", |w| self.allocation.walk(w));
}
}
impl Historical for OccurrenceEndpoint {
fn walk(&mut self, w: &mut Walk<'_>) {
if self.references.len() > crate::topology_context::MAX_REFERENCE_DEPTH {
w.error("historical contextual path depth exceeded");
}
w.at("references", |w| self.references.walk(w));
w.at("occurrence", |w| self.occurrence.walk(w));
}
}
impl Historical for ContextualLinkPurpose {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
Self::Precedence { guard } => w.at("Precedence", |w| w.at("guard", |w| guard.walk(w))),
Self::Exchange { exchange } => {
w.at("Exchange", |w| w.at("exchange", |w| exchange.walk(w)))
}
Self::CommonWork { work } => w.at("CommonWork", |w| w.at("work", |w| work.walk(w))),
}
}
}
impl Historical for DataValue {
fn walk(&mut self, w: &mut Walk<'_>) {
match self {
+5 -1
View File
@@ -518,7 +518,11 @@ pub(crate) fn constructor_contains(group: &FacetSelector, facet: &FacetSelector)
}
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
facet,
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
F::Occurrence(
OccurrenceFacet::MessageKind
| OccurrenceFacet::TimerExpression
| OccurrenceFacet::Operation
)
),
F::Event(EventFacet::Constructor) => matches!(
facet,
+3
View File
@@ -112,6 +112,7 @@ macro_rules! scalar_families {
Constructor: OccurrenceConstructor, Precondition: String, Postcondition: String,
Exchange: Option<Id>, Sender: Option<Id>, Receiver: Option<Id>,
MessageKind: MessageConstructor, TimerExpression: String, ReplyTo: Option<Id>,
Operation: Option<OperationUse>,
Action: Option<Id>, ActionLifeline: Option<Id>, ControlKind: ControlKind,
Sequence: Option<Id>, Scenario: Option<Id>, State: Option<Id>,
StateLifeline: Option<Id>, FragmentOperator: FragmentOperator, BranchGuard: String
@@ -129,6 +130,8 @@ macro_rules! scalar_families {
SourcePort: Id, TargetPort: Id, Oriented: bool, ExtendCondition: String,
SequenceGuard: String, TransitionKind: TransitionKind, Trigger: String,
Guard: String, Effect: String, StateActionPhase: StateActionKind
, ContextOwner: Id, ContextSource: OccurrenceEndpoint, ContextTarget: OccurrenceEndpoint,
ContextPurpose: ContextualLinkPurpose
}
}};
}
+1
View File
@@ -660,6 +660,7 @@ fn apply_command(
.unwrap();
*occ.shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: Some(*exchange),
sender: None,
receiver: None,
+5 -1
View File
@@ -1553,7 +1553,11 @@ fn semantic_owner(project: &Project, id: Id) -> Option<Id> {
return element.parent;
}
if let Some(relation) = project.relations.get(&id) {
return Some(relation.source());
return Some(
relation
.contextual_owner()
.unwrap_or_else(|| relation.source()),
);
}
if let Some(artifact) = project.artifact_links.get(&id) {
return Some(artifact.owner);
+352
View File
@@ -0,0 +1,352 @@
//! Caller-qualified process/chain addresses. Cache residency never affects admission.
use crate::*;
use std::{
cell::RefCell,
collections::{BTreeMap, BTreeSet},
};
mod analysis;
pub use analysis::*;
pub const MAX_REFERENCE_DEPTH: usize = 64;
pub const MAX_ANALYSIS_NODES: usize = 50_000;
pub const MAX_ANALYSIS_EDGES: usize = 100_000;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ContextAddress {
pub owner: Id,
pub endpoint: OccurrenceEndpoint,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedOccurrence {
pub address: ContextAddress,
pub defining_sequence: Id,
pub work: Option<Id>,
pub exchange: Option<Id>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct ContextError {
pub code: &'static str,
pub message: String,
}
fn failure(code: &'static str, message: &str) -> ContextError {
ContextError {
code,
message: message.into(),
}
}
struct Record {
parent: Option<Id>,
layer: Layer,
kind: Kind,
reference: Option<Id>,
work: Option<Id>,
exchange: Option<Id>,
contribution: bool,
}
pub struct TopologyIndex<'a> {
project: &'a Project,
records: BTreeMap<Id, Record>,
children: BTreeMap<Id, Vec<Id>>,
link_definitions: BTreeMap<Id, Vec<Id>>,
contextual_callers: BTreeSet<Id>,
cache: RefCell<BTreeMap<ContextAddress, ResolvedOccurrence>>,
cache_capacity: usize,
}
impl<'a> TopologyIndex<'a> {
pub fn new(project: &'a Project) -> Self {
Self::with_cache_capacity(project, 4096)
}
pub fn with_cache_capacity(project: &'a Project, cache_capacity: usize) -> Self {
let records: BTreeMap<_, _> = project
.elements
.values()
.map(|e| {
let (reference, work, exchange, contribution) = match e.shared() {
Some(SharedBody::Occurrence { body, .. }) => match body {
OccurrenceBody::SequenceReference { sequence } => {
(*sequence, None, None, true)
}
OccurrenceBody::Action { action, .. } => (None, *action, None, true),
OccurrenceBody::Exchange { exchange, .. } => (None, None, *exchange, true),
OccurrenceBody::Control { .. } => (None, None, None, true),
_ => (None, None, None, false),
},
_ => (None, None, None, false),
};
(
e.id,
Record {
parent: e.parent,
layer: e.layer(),
kind: e.kind(),
reference,
work,
exchange,
contribution,
},
)
})
.collect();
let mut children: BTreeMap<_, _> = project
.elements
.values()
.filter_map(|e| e.steps().map(|steps| (e.id, steps.clone())))
.collect();
let mut child_sets: BTreeMap<_, BTreeSet<_>> = children
.iter()
.map(|(owner, children)| (*owner, children.iter().copied().collect()))
.collect();
for (id, record) in &records {
if record.contribution
&& let Some(parent) = record.parent
{
let list = children.entry(parent).or_default();
if child_sets.entry(parent).or_default().insert(*id) {
list.push(*id);
}
}
}
for (owner, list) in &mut children {
list.retain(|id| {
records
.get(id)
.is_some_and(|r| r.parent == Some(*owner) && r.contribution)
});
}
let mut link_definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
let mut contextual_callers = BTreeSet::new();
for relation in project.relations.values() {
let owner = relation.contextual_owner().or_else(|| {
if relation.kind() == RelationKind::SequenceLink {
records.get(&relation.source()).and_then(|r| r.parent)
} else {
None
}
});
if let Some(owner) = owner {
link_definitions.entry(owner).or_default().push(relation.id);
}
if let Some(owner) = relation.contextual_owner() {
contextual_callers.insert(owner);
}
}
let mut callers: BTreeMap<_, Vec<_>> = BTreeMap::new();
for record in records.values() {
if let (Some(parent), Some(reference)) = (record.parent, record.reference) {
callers.entry(reference).or_default().push(parent);
}
}
let mut pending: Vec<_> = contextual_callers.iter().copied().collect();
while let Some(sequence) = pending.pop() {
for caller in callers.get(&sequence).into_iter().flatten() {
if contextual_callers.insert(*caller) {
pending.push(*caller);
}
}
}
Self {
project,
records,
children,
link_definitions,
contextual_callers,
cache: RefCell::new(BTreeMap::new()),
cache_capacity,
}
}
/// Resolve a caller path while an endpoint leaf is still being edited.
pub fn resolve_scope(&self, owner: Id, references: &[Id]) -> Result<Id, ContextError> {
if references.len() > MAX_REFERENCE_DEPTH {
return Err(failure(
"context_depth",
"A contextual path exceeds 64 references.",
));
}
let root = self.records.get(&owner).ok_or_else(|| {
failure(
"context_owner",
"The caller must be an existing process or functional chain.",
)
})?;
if !matches!(root.kind, Kind::Process | Kind::FunctionalChain) {
return Err(failure(
"context_owner",
"The caller must be a process or functional chain.",
));
}
let mut current = owner;
let mut visited = BTreeSet::from([owner]);
for step in references {
let reference = self.records.get(step).ok_or_else(|| {
failure(
"context_path",
"A reference occurrence in the endpoint path no longer exists.",
)
})?;
if reference.parent != Some(current) || reference.layer != root.layer {
return Err(failure(
"context_path",
"Each reference occurrence must belong directly to the preceding caller context.",
));
}
let next = reference.reference.ok_or_else(|| {
failure(
"context_path",
"Each path component must be a resolved sequence-reference occurrence.",
)
})?;
let target = self.records.get(&next).ok_or_else(|| {
failure(
"context_path",
"A referenced process or chain no longer exists.",
)
})?;
if target.layer != root.layer || target.kind != root.kind {
return Err(failure(
"context_path",
"References must resolve to sequences of the same kind and layer.",
));
}
if !visited.insert(next) {
return Err(failure(
"context_cycle",
"A contextual endpoint cannot traverse recursive sequence reuse.",
));
}
current = next;
}
Ok(current)
}
pub fn resolve(
&self,
owner: Id,
endpoint: &OccurrenceEndpoint,
) -> Result<ResolvedOccurrence, ContextError> {
if endpoint.references.len() > MAX_REFERENCE_DEPTH {
return Err(failure(
"context_depth",
"A contextual endpoint exceeds the 64-reference depth limit.",
));
}
let address = ContextAddress {
owner,
endpoint: endpoint.clone(),
};
if let Some(cached) = self.cache.borrow().get(&address) {
return Ok(cached.clone());
}
let current = self.resolve_scope(owner, &endpoint.references)?;
let root = &self.records[&owner];
let leaf = self.records.get(&endpoint.occurrence).ok_or_else(|| {
failure(
"context_leaf",
"The endpoint contribution no longer exists.",
)
})?;
if leaf.parent != Some(current) || leaf.layer != root.layer || !leaf.contribution {
return Err(failure(
"context_leaf",
"The endpoint must be a direct contribution of its resolved process or chain.",
));
}
let resolved = ResolvedOccurrence {
address: address.clone(),
defining_sequence: current,
work: leaf.work,
exchange: leaf.exchange,
};
if self.cache_capacity > 0 {
let mut cache = self.cache.borrow_mut();
if cache.len() >= self.cache_capacity {
cache.pop_first();
}
cache.insert(address, resolved.clone());
}
Ok(resolved)
}
/// Validate a caller-owned semantic connection, independent of graphical expansion.
pub fn validate_link(
&self,
owner: Id,
source: &OccurrenceEndpoint,
target: &OccurrenceEndpoint,
purpose: &ContextualLinkPurpose,
) -> Result<(ResolvedOccurrence, ResolvedOccurrence), ContextError> {
let a = self.resolve(owner, source)?;
let b = self.resolve(owner, target)?;
if a.address == b.address {
return Err(failure(
"context_self",
"A contextual link needs two distinct occurrence addresses.",
));
}
match purpose {
ContextualLinkPurpose::Precedence { guard } => {
if source.references.is_empty() && target.references.is_empty() {
return Err(failure(
"context_direct_precedence",
"Use a SequenceLink for precedence between two direct contributions.",
));
}
if !validation::valid_text(guard) {
return Err(failure(
"invalid_text",
"The precedence guard contains invalid text.",
));
}
let previous = a.exchange.and_then(|id| self.project.relations.get(&id));
let next = b.exchange.and_then(|id| self.project.relations.get(&id));
let mismatch = match (a.work, previous, b.work, next) {
(Some(work), _, _, Some(exchange)) => work != exchange.source(),
(_, Some(exchange), Some(work), _) => exchange.target() != work,
(_, Some(previous), _, Some(next)) => previous.target() != next.source(),
_ => false,
};
if mismatch {
return Err(failure(
"sequence_exchange_endpoint",
"Adjacent work/exchange contributions must agree with the referenced exchange endpoints.",
));
}
}
ContextualLinkPurpose::Exchange { exchange } => {
let relation = self.project.relations.get(exchange).ok_or_else(|| {
failure("context_exchange", "Choose an existing work exchange.")
})?;
let expected = if self.project.layer_of(owner) == Some(Layer::Oa) {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
};
if relation.kind() != expected
|| self.project.layer_of(*exchange) != self.project.layer_of(owner)
|| a.work != Some(relation.source())
|| b.work != Some(relation.target())
{
return Err(failure(
"context_exchange",
"The selected same-layer exchange must connect the exact work referenced by the source and target Actions.",
));
}
}
ContextualLinkPurpose::CommonWork { work } => {
if !self.project.elements.get(work).is_some_and(|e| {
e.kind().is_work() && Some(e.layer()) == self.project.layer_of(owner)
}) || a.work != Some(*work)
|| b.work != Some(*work)
{
return Err(failure(
"context_common_work",
"Both Actions must reference the exact same existing work in this layer.",
));
}
}
}
Ok((a, b))
}
/// Direct child candidates for lazy UI exploration; never expands a Cartesian reuse tree.
pub fn contributions(&self, sequence: Id) -> Vec<Id> {
self.children.get(&sequence).cloned().unwrap_or_default()
}
}
@@ -0,0 +1,529 @@
use super::*;
#[cfg(test)]
#[path = "analysis_tests.rs"]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ReferenceBoundary {
Entry,
Exit,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ContextVertex {
pub address: ContextAddress,
pub boundary: Option<ReferenceBoundary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextEdgeKind {
Authored,
SyntheticBoundary,
LegacyOrder,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextEdge {
pub relation: Option<Id>,
pub instance: Vec<Id>,
pub source: ContextVertex,
pub target: ContextVertex,
pub guard: String,
pub kind: ContextEdgeKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextAssembly {
pub relation: Id,
pub instance: Vec<Id>,
pub source: ContextAddress,
pub target: ContextAddress,
pub purpose: ContextualLinkPurpose,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextNotice {
pub address: ContextAddress,
pub code: &'static str,
pub message: String,
pub relations: Vec<Id>,
}
#[derive(Debug, Clone, Copy)]
pub struct AnalysisLimits {
pub nodes: usize,
pub edges: usize,
}
impl Default for AnalysisLimits {
fn default() -> Self {
Self {
nodes: MAX_ANALYSIS_NODES,
edges: MAX_ANALYSIS_EDGES,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ContextAnalysis {
pub nodes: BTreeMap<ContextAddress, ResolvedOccurrence>,
pub precedence: Vec<ContextEdge>,
pub assembly: Vec<ContextAssembly>,
pub entries: Vec<ContextVertex>,
pub exits: Vec<ContextVertex>,
pub notices: Vec<ContextNotice>,
/// A partial expansion must never be used for complete reachability conclusions.
pub complete: bool,
}
struct Builder<'a, 'p> {
index: &'a TopologyIndex<'p>,
root: Id,
limits: AnalysisLimits,
result: ContextAnalysis,
definitions: BTreeMap<Id, Vec<&'p Relation>>,
examined_edges: usize,
exhausted: bool,
}
impl TopologyIndex<'_> {
pub fn analyze(&self, owner: Id, limits: AnalysisLimits) -> ContextAnalysis {
self.analyze_counted(owner, limits).0
}
fn analyze_counted(&self, owner: Id, limits: AnalysisLimits) -> (ContextAnalysis, usize) {
let mut definitions: BTreeMap<_, Vec<_>> = BTreeMap::new();
for (owner, relations) in &self.link_definitions {
definitions.insert(
*owner,
relations
.iter()
.filter_map(|id| self.project.relations.get(id))
.collect(),
);
}
let mut b = Builder {
index: self,
root: owner,
limits,
result: ContextAnalysis {
complete: true,
..ContextAnalysis::default()
},
definitions,
examined_edges: 0,
exhausted: false,
};
(b.result.entries, b.result.exits) = b.expand(owner, &[], &mut BTreeSet::new());
if b.result.complete {
b.cardinality();
b.connectivity();
}
(b.result, b.examined_edges)
}
pub fn has_contextual_links(&self, owner: Id) -> bool {
self.contextual_callers.contains(&owner)
}
}
impl Builder<'_, '_> {
fn address(&self, path: &[Id], occurrence: Id) -> ContextAddress {
ContextAddress {
owner: self.root,
endpoint: OccurrenceEndpoint {
references: path.to_vec(),
occurrence,
},
}
}
fn vertex(&self, address: ContextAddress, source: bool) -> ContextVertex {
let reference = self
.index
.project
.elements
.get(&address.endpoint.occurrence)
.and_then(Element::shared)
.is_some_and(|s| {
matches!(
s,
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
}
)
});
ContextVertex {
address,
boundary: reference.then_some(if source {
ReferenceBoundary::Exit
} else {
ReferenceBoundary::Entry
}),
}
}
fn incomplete(&mut self, address: ContextAddress, code: &'static str, message: &str) {
self.result.complete = false;
if !self
.result
.notices
.iter()
.any(|n| n.address == address && n.code == code)
{
self.result.notices.push(ContextNotice {
address,
code,
message: message.into(),
relations: vec![],
});
}
}
// Reserve work before resolving an authored endpoint, not merely before storing
// its output. Repeated malformed or filtered endpoints consume the same budget.
fn reserve_edge(&mut self, address: ContextAddress) -> bool {
if self.exhausted {
return false;
}
if self.examined_edges >= self.limits.edges {
self.exhausted = true;
self.incomplete(
address,
"context_analysis_limit",
"Contextual edge limit reached; connectivity analysis is incomplete.",
);
return false;
}
self.examined_edges += 1;
true
}
fn edge(&mut self, edge: ContextEdge) -> bool {
if !self.reserve_edge(edge.source.address.clone()) {
return false;
}
self.result.precedence.push(edge);
true
}
fn expand(
&mut self,
sequence: Id,
prefix: &[Id],
active: &mut BTreeSet<Id>,
) -> (Vec<ContextVertex>, Vec<ContextVertex>) {
if self.exhausted {
return (vec![], vec![]);
}
if prefix.len() > MAX_REFERENCE_DEPTH || !active.insert(sequence) {
self.incomplete(
self.address(prefix, sequence),
"context_analysis_incomplete",
"Recursive or over-depth reuse has no proven boundary path.",
);
return (vec![], vec![]);
}
let children = self.index.contributions(sequence);
if children.is_empty() {
self.incomplete(
self.address(prefix, sequence),
"context_analysis_incomplete",
"An empty or unresolved sequence has no proven boundary path.",
);
active.remove(&sequence);
return (vec![], vec![]);
}
let start = self.result.precedence.len();
for id in &children {
let address = self.address(prefix, *id);
if self.result.nodes.len() >= self.limits.nodes {
self.exhausted = true;
self.incomplete(
address,
"context_analysis_limit",
"Contextual node limit reached; connectivity analysis is incomplete.",
);
break;
}
match self.index.resolve(self.root, &address.endpoint) {
Ok(resolved) => {
self.result.nodes.insert(address.clone(), resolved);
}
Err(e) => {
self.incomplete(address, "context_analysis_incomplete", &e.message);
continue;
}
}
let reference = self.index.project.elements[id]
.shared()
.and_then(|s| match s {
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence },
..
} => Some(*sequence),
_ => None,
});
if let Some(reference) = reference {
let Some(callee) = reference else {
self.incomplete(
address,
"context_analysis_incomplete",
"An unresolved reference has no proven boundary path.",
);
continue;
};
let mut path = prefix.to_vec();
path.push(*id);
let (entries, exits) = self.expand(callee, &path, active);
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
if entries.is_empty() || exits.is_empty() {
self.incomplete(
address.clone(),
"context_analysis_incomplete",
"No referenced entry or exit is proven; no bypass is inferred.",
);
}
for target in entries {
if !self.edge(ContextEdge {
relation: None,
instance: prefix.to_vec(),
source: self.vertex(address.clone(), false),
target,
guard: String::new(),
kind: ContextEdgeKind::SyntheticBoundary,
}) {
break;
}
}
for source in exits {
if !self.edge(ContextEdge {
relation: None,
instance: prefix.to_vec(),
source,
target: self.vertex(address.clone(), true),
guard: String::new(),
kind: ContextEdgeKind::SyntheticBoundary,
}) {
break;
}
}
}
if self.exhausted {
break;
}
}
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
let relations = self.definitions.get(&sequence).cloned().unwrap_or_default();
for r in &relations {
if !self.reserve_edge(self.address(prefix, r.source())) {
break;
}
let (source, target, purpose) = match &r.body {
RelationBody::SequenceLink {
source,
target,
guard,
} => (
OccurrenceEndpoint {
references: vec![],
occurrence: *source,
},
OccurrenceEndpoint {
references: vec![],
occurrence: *target,
},
ContextualLinkPurpose::Precedence {
guard: guard.clone(),
},
),
RelationBody::ContextualLink {
source,
target,
purpose,
..
} => (source.clone(), target.clone(), purpose.clone()),
_ => continue,
};
let qualify = |mut endpoint: OccurrenceEndpoint| {
let mut path = prefix.to_vec();
path.extend(endpoint.references);
endpoint.references = path;
ContextAddress {
owner: self.root,
endpoint,
}
};
let source = qualify(source);
let target = qualify(target);
if !self.result.nodes.contains_key(&source) || !self.result.nodes.contains_key(&target)
{
self.incomplete(
source,
"context_analysis_incomplete",
"A link endpoint is unresolved in this bounded analysis.",
);
continue;
}
match purpose {
ContextualLinkPurpose::Precedence { guard } => {
self.result.precedence.push(ContextEdge {
relation: Some(r.id),
instance: prefix.to_vec(),
source: self.vertex(source, true),
target: self.vertex(target, false),
guard,
kind: ContextEdgeKind::Authored,
})
}
purpose => {
self.result.assembly.push(ContextAssembly {
relation: r.id,
instance: prefix.to_vec(),
source,
target,
purpose,
});
}
}
}
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
if relations.is_empty()
&& children.iter().all(|id| {
matches!(
self.index.project.elements[id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { .. },
..
})
)
})
{
for pair in children.windows(2) {
if !self.edge(ContextEdge {
relation: None,
instance: prefix.to_vec(),
source: self.vertex(self.address(prefix, pair[0]), true),
target: self.vertex(self.address(prefix, pair[1]), false),
guard: String::new(),
kind: ContextEdgeKind::LegacyOrder,
}) {
break;
}
}
}
if self.exhausted {
active.remove(&sequence);
return (vec![], vec![]);
}
// Intrinsic boundaries are determined before any enclosing caller links.
let edges = &self.result.precedence[start..];
let targets: BTreeSet<_> = edges.iter().map(|e| &e.target).collect();
let sources: BTreeSet<_> = edges.iter().map(|e| &e.source).collect();
let entries = children
.iter()
.map(|id| self.vertex(self.address(prefix, *id), false))
.filter(|v| !targets.contains(v))
.collect();
let exits = children
.iter()
.map(|id| self.vertex(self.address(prefix, *id), true))
.filter(|v| !sources.contains(v))
.collect();
active.remove(&sequence);
(entries, exits)
}
fn cardinality(&mut self) {
// Index authored incidence once; synthetic boundaries never count as controls.
let mut incoming: BTreeMap<_, Vec<_>> = BTreeMap::new();
let mut outgoing: BTreeMap<_, Vec<_>> = BTreeMap::new();
for edge in &self.result.precedence {
if edge.kind == ContextEdgeKind::Authored {
incoming.entry(&edge.target.address).or_default().push(edge);
outgoing.entry(&edge.source.address).or_default().push(edge);
}
}
for address in self.result.nodes.keys() {
let ins = incoming.get(address).cloned().unwrap_or_default();
let outs = outgoing.get(address).cloned().unwrap_or_default();
let before = ins.len();
let after = outs.len();
let control = match self.index.project.elements[&address.endpoint.occurrence].shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Control { kind },
..
}) => Some(*kind),
_ => None,
};
let issue = match control {
Some(kind) => { let valid = match kind { ControlKind::Choice | ControlKind::Fork => before == 1 && after >= 2, ControlKind::Merge | ControlKind::Join => before >= 2 && after == 1, ControlKind::Loop => before >= 1 && after >= 2 }; (!valid).then_some(("control_cardinality", format!("{kind:?} has {before} incoming and {after} outgoing authored precedence links in this caller context. Review the intended paths."))) }
None => (before > 1 || after > 1).then_some(("ambiguous_sequence_branch", "This contribution has multiple incoming or outgoing precedence links in this caller context. Review whether explicit merge/join or branching control is needed to describe the intended paths.".into())),
};
if let Some((code, message)) = issue {
self.result.notices.push(ContextNotice {
address: address.clone(),
code,
message,
relations: ins.iter().chain(&outs).filter_map(|e| e.relation).collect(),
});
}
if matches!(control, Some(ControlKind::Choice | ControlKind::Loop)) {
for edge in outs.iter().filter(|e| e.guard.trim().is_empty()) {
self.result.notices.push(ContextNotice { address: address.clone(), code: "missing_sequence_guard", message: "Describe the choice/loop condition for this path, or use an explicit 'else' guard.".into(), relations: edge.relation.into_iter().collect() });
}
}
}
}
fn connectivity(&mut self) {
let mut forward: BTreeMap<_, Vec<_>> = BTreeMap::new();
let mut reverse: BTreeMap<_, Vec<_>> = BTreeMap::new();
for edge in &self.result.precedence {
forward
.entry(edge.source.clone())
.or_default()
.push(edge.target.clone());
reverse
.entry(edge.target.clone())
.or_default()
.push(edge.source.clone());
}
let visit = |starts: &[ContextVertex],
graph: &BTreeMap<ContextVertex, Vec<ContextVertex>>| {
let mut pending = starts.to_vec();
let mut seen = BTreeSet::new();
while let Some(vertex) = pending.pop() {
if seen.insert(vertex.clone()) {
pending.extend(graph.get(&vertex).into_iter().flatten().cloned());
}
}
seen
};
let from_start = visit(&self.result.entries, &forward);
let to_end = visit(&self.result.exits, &reverse);
for address in self.result.nodes.keys() {
let entry = self.vertex(address.clone(), false);
let exit = self.vertex(address.clone(), true);
for (missing, code, message) in [
(
!from_start.contains(&entry),
"unreachable_contribution",
"No path from a structural caller entry reaches this contribution in this context.",
),
(
!to_end.contains(&exit),
"nonterminating_contribution",
"No structural caller exit is reachable from this contribution in this context; review the intended loop.",
),
(
self.result.nodes.len() > 1
&& !reverse.contains_key(&entry)
&& !forward.contains_key(&exit),
"isolated_contribution",
"This contribution has no incoming or outgoing precedence path in this caller context; assembly incidence is separate.",
),
] {
if missing {
self.result.notices.push(ContextNotice {
address: address.clone(),
code,
message: message.into(),
relations: vec![],
});
}
}
}
}
}
@@ -0,0 +1,172 @@
use super::*;
fn occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let id = p
.add_element(Kind::Occurrence, Layer::Sa, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn repeated(references: usize, links: usize) -> (Project, Id, Id) {
let mut p = Project::empty();
let root = p
.add_element(Kind::FunctionalChain, Layer::Sa, "Caller", None)
.unwrap();
let procedure = p
.add_element(Kind::FunctionalChain, Layer::Sa, "Reusable procedure", None)
.unwrap();
let mut leaves = Vec::new();
for name in ["A", "B"] {
let action = p
.add_element(Kind::Function, Layer::Sa, name, None)
.unwrap();
leaves.push(occurrence(
&mut p,
procedure,
name,
OccurrenceBody::Action {
action: Some(action),
lifeline: None,
},
));
}
for _ in 0..links {
p.relate(
RelationKind::SequenceLink,
"Authored A to B",
leaves[0],
leaves[1],
);
}
for _ in 0..references {
occurrence(
&mut p,
root,
"Reuse",
OccurrenceBody::SequenceReference {
sequence: Some(procedure),
},
);
}
(p, root, procedure)
}
#[test]
fn edge_work_budget_stops_repeated_expansion_not_just_output_storage() {
// Only ~3,000 possible contribution nodes, but a million authored edge
// instances. The edge budget must stop actual work before later references.
let (p, root, _) = repeated(1_000, 1_000);
let limits = AnalysisLimits::default();
let index = TopologyIndex::with_cache_capacity(&p, 1);
let (first, attempts) = index.analyze_counted(root, limits);
assert_eq!(attempts, limits.edges);
assert_eq!(first.precedence.len(), limits.edges);
assert!(!first.complete);
assert!(first.entries.is_empty() && first.exits.is_empty());
assert!(
first.nodes.len() < 1_000,
"later caller references must not be expanded"
);
assert_eq!(
first
.notices
.iter()
.filter(|n| n.code == "context_analysis_limit")
.count(),
1
);
assert!(
first
.notices
.iter()
.all(|n| n.code == "context_analysis_limit")
);
let (warm, warm_attempts) = index.analyze_counted(root, limits);
assert_eq!(warm_attempts, attempts);
assert_eq!(warm.nodes, first.nodes);
assert_eq!(warm.precedence, first.precedence);
assert_eq!(warm.notices, first.notices);
drop(warm);
let (uncached, uncached_attempts) =
TopologyIndex::with_cache_capacity(&p, 0).analyze_counted(root, limits);
assert_eq!(uncached_attempts, attempts);
assert_eq!(uncached.precedence, first.precedence);
assert_eq!(uncached.notices, first.notices);
}
#[test]
fn unresolved_authored_edges_consume_work_budget_without_stored_output() {
let (mut p, root, _) = repeated(20, 100);
let missing = Id::nil();
for relation in p.relations.values_mut() {
relation.body.set_endpoints(relation.source(), missing);
}
let (result, attempts) = TopologyIndex::new(&p).analyze_counted(
root,
AnalysisLimits {
nodes: 1_000,
edges: 17,
},
);
assert_eq!(attempts, 17);
assert!(result.precedence.is_empty());
assert_eq!(result.nodes.len(), 3);
assert!(!result.complete);
assert!(result.entries.is_empty() && result.exits.is_empty());
assert_eq!(
result
.notices
.iter()
.filter(|n| n.code == "context_analysis_limit")
.count(),
1
);
}
#[test]
fn exact_edge_budget_includes_reference_boundaries_and_node_abort_has_no_bypass() {
let (p, root, _) = repeated(1, 2);
let index = TopologyIndex::new(&p);
let (complete, count) = index.analyze_counted(root, AnalysisLimits { nodes: 3, edges: 4 });
assert!(complete.complete);
assert_eq!(count, 4); // two authored edges plus entry and exit connectors
assert_eq!(
complete
.precedence
.iter()
.filter(|e| e.kind == ContextEdgeKind::SyntheticBoundary)
.count(),
2
);
for limits in [
AnalysisLimits { nodes: 3, edges: 3 },
AnalysisLimits {
nodes: 1,
edges: 10,
},
] {
let (partial, count) = index.analyze_counted(root, limits);
assert!(!partial.complete);
assert!(partial.entries.is_empty() && partial.exits.is_empty());
assert!(count <= limits.edges);
assert_eq!(
partial
.notices
.iter()
.filter(|n| n.code == "context_analysis_limit")
.count(),
1
);
}
}
+97 -7
View File
@@ -81,6 +81,12 @@ pub struct CommunicationEndpointDecision {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OperationOmission {
pub source: Id,
pub rationale: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionRequest {
pub direction: TransitionDirection,
pub sources: Vec<Id>,
@@ -91,6 +97,8 @@ pub struct TransitionRequest {
pub destination_diagram: Option<Id>,
#[serde(default)]
pub communication_endpoints: Vec<CommunicationEndpointDecision>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub operation_omissions: Vec<OperationOmission>,
}
impl TransitionRequest {
pub fn new(direction: TransitionDirection, sources: Vec<Id>) -> Self {
@@ -103,6 +111,7 @@ impl TransitionRequest {
resolutions: vec![],
destination_diagram: None,
communication_endpoints: vec![],
operation_omissions: vec![],
}
}
}
@@ -319,6 +328,7 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
| RelationBody::MissionInvolvement { .. }
| RelationBody::MissionCapability { .. }
| RelationBody::SequenceLink { .. }
| RelationBody::ContextualLink { .. }
| RelationBody::ChainInvolvement { .. }
| RelationBody::BoundaryExchange { .. }
| RelationBody::InterfaceProvision { .. }
@@ -500,7 +510,16 @@ pub fn plan_transition(
continue;
}
if let Some(e) = project.elements.get(&id) {
selected.extend(crate::references::element_references(e));
let mut dependencies = e.clone();
if request.direction == TransitionDirection::SaToOa
&& let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = dependencies.shared_mut()
{
*operation = None;
}
selected.extend(crate::references::element_references(&dependencies));
// Allocations are owned by interfaces. Independent data
// associations travel only when both endpoint types are
// already required; they do not expand the dictionary.
@@ -523,12 +542,13 @@ pub fn plan_transition(
// their endpoint involvements, independently of list order.
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
selected.extend(project.relations.values().filter_map(|relation| {
(relation.kind() == RelationKind::SequenceLink
&& project
.elements
.get(&relation.source())
.and_then(|e| e.parent)
== Some(id))
(relation.contextual_owner() == Some(id)
|| (relation.kind() == RelationKind::SequenceLink
&& project
.elements
.get(&relation.source())
.and_then(|e| e.parent)
== Some(id)))
.then_some(relation.id)
}));
}
@@ -594,6 +614,67 @@ pub fn plan_transition(
false
} else { true }
});
let mut operation_omissions = BTreeSet::new();
for decision in &request.operation_omissions {
if request.direction != TransitionDirection::SaToOa
|| !selected.contains(&decision.source)
|| !matches!(
project
.elements
.get(&decision.source)
.and_then(Element::shared),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(_),
message_kind: MessageKind::Call | MessageKind::Asynchronous,
..
},
..
})
)
|| decision.rationale.trim().is_empty()
|| !validation::valid_text(&decision.rationale)
|| !operation_omissions.insert(decision.source)
{
conflict(
&mut plan,
Some(decision.source),
None,
None,
"Operation omission needs one selected SA invocation, SA→OA direction, and a nonempty rationale; irrelevant or duplicate decisions reject.",
);
} else {
plan.warnings.push(format!(
"Reviewed operation omission: {} ({}): {}. Source contract is preserved.",
project.named(decision.source),
decision.source,
decision.rationale
));
}
}
if request.direction == TransitionDirection::SaToOa {
for source in &selected {
if matches!(
project.elements.get(source).and_then(Element::shared),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(_),
..
},
..
})
) && !operation_omissions.contains(source)
{
conflict(
&mut plan,
Some(*source),
None,
None,
"This technical operation has no OA operation slot. Review an explicit per-invocation omission with rationale; Reply derives the Call's decision.",
);
}
}
}
let mut choices = BTreeMap::new();
for mapping in &request.mappings {
if choices.insert(mapping.source, mapping).is_some() {
@@ -787,6 +868,15 @@ pub fn plan_transition(
let mut created = e.clone();
created.id = target;
created.body = body;
if request.direction == TransitionDirection::SaToOa
&& operation_omissions.contains(source)
&& let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = created.shared_mut()
{
*operation = None;
}
created.alias = working.fresh_alias(
&format!(
"{}_{}",
+149 -7
View File
@@ -182,6 +182,7 @@ fn check_ids(out: &mut Vec<Issue>, owner: Id, field: &str, ids: &[Id]) {
pub fn validate(p: &Project) -> Vec<Issue> {
let mut out = Vec::new();
let coverage = coverage::Index::new(p);
let topology_index = crate::topology_context::TopologyIndex::new(p);
if p.schema_version != SCHEMA_VERSION {
error(
&mut out,
@@ -383,7 +384,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
"Add ordered occurrences.",
);
}
validate_sequence(p, e, steps, &mut out);
validate_sequence(p, e, steps, &topology_index, &mut out);
}
if let Some(shared) = e.shared() {
match shared {
@@ -600,7 +601,7 @@ 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);
validate_relation(p, r, &topology_index, &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);
@@ -950,10 +951,57 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
out
}
fn validate_sequence(p: &Project, e: &Element, steps: &[Id], out: &mut Vec<Issue>) {
fn validate_sequence(
p: &Project,
e: &Element,
steps: &[Id],
topology: &crate::topology_context::TopologyIndex<'_>,
out: &mut Vec<Issue>,
) {
if !matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
return;
}
if topology.has_contextual_links(e.id) {
let analysis = topology.analyze(e.id, crate::topology_context::AnalysisLimits::default());
for notice in analysis.notices {
let breadcrumb = notice
.address
.endpoint
.references
.iter()
.chain(std::iter::once(&notice.address.endpoint.occurrence))
.map(|id| format!("{} ({id})", p.named(*id)))
.collect::<Vec<_>>()
.join(" / ");
warning(
out,
e.id,
notice.code,
"steps",
format!("Caller '{}', {breadcrumb}: {}", e.name, notice.message),
);
}
if analysis.complete {
for (code, label, count) in [
("sequence_starts", "start", analysis.entries.len()),
("sequence_ends", "end", analysis.exits.len()),
] {
if count != 1 {
warning(
out,
e.id,
code,
"steps",
format!(
"Caller '{}' has {count} structural {label} boundaries; review the intended independent paths.",
e.name
),
);
}
}
}
return;
}
let links: Vec<_> = p
.relations
.values()
@@ -1554,6 +1602,7 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
receiver,
reply_to,
values,
..
} = body
else {
return;
@@ -1766,6 +1815,34 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
}
}
let relation = exchange.and_then(|id| p.relations.get(&id));
let operation = crate::operations::resolve(p, e.id);
let signature = match &operation {
Ok(crate::operations::OperationResolution::Selected(selected)) => {
if selected.transmission == TransmissionProtocol::Unspecified {
warning(
out,
e.id,
"operation_transmission_unspecified",
"operation",
"The selected operation transmission protocol is unspecified.",
);
}
Some(crate::operations::signature_relation(selected))
}
Ok(crate::operations::OperationResolution::Unresolved { reason, .. }) => {
warning(out, e.id, "operation_unresolved", "operation", reason);
None
}
Err(problem) => {
error(out, e.id, problem.code, "operation", &problem.message);
None
}
_ => None,
};
let value_relation = match &operation {
Ok(crate::operations::OperationResolution::Ordinary) => relation,
_ => signature.as_ref(),
};
if message_kind == &MessageKind::Call
&& relation.is_some_and(|r| {
!r.body
@@ -1811,10 +1888,48 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
"The value path exceeds the bounded authoring depth.",
);
}
if let Some(r) = relation {
if matches!(
operation,
Ok(crate::operations::OperationResolution::Unresolved { .. })
) && let Some(owner) = value
.path
.first()
.or(Some(&value.field))
.and_then(|id| p.elements.get(id))
.and_then(|field| field.parent)
{
let path_context = Relation {
id: e.id,
alias: String::new(),
name: String::new(),
description: String::new(),
body: RelationBody::Interaction {
source: e.id,
target: e.id,
items: vec![owner],
},
};
if crate::data::carried_value_path(p, &path_context, &value.path, value.field).is_none()
{
error(
out,
e.id,
"message_value_path",
"values.path",
"Even while the operation is unresolved, a value path must follow existing Value record fields in order.",
);
}
}
if let Some(r) = value_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::Asynchronous if signature.is_some() => matches!(
path.direction,
ParameterDirection::Unspecified
| ParameterDirection::In
| ParameterDirection::InOut
),
MessageKind::Call => matches!(
path.direction,
ParameterDirection::Unspecified
@@ -2254,7 +2369,24 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo
false
}
fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
fn validate_relation(
p: &Project,
r: &Relation,
topology: &crate::topology_context::TopologyIndex<'_>,
out: &mut Vec<Issue>,
) {
if let RelationBody::ContextualLink {
owner,
source,
target,
purpose,
} = &r.body
{
if let Err(problem) = topology.validate_link(*owner, source, target, purpose) {
error(out, r.id, problem.code, "body", problem.message);
}
return;
}
let (source, target) = r.body.endpoints();
for id in crate::references::relation_references(r) {
// Most relation references name elements. Avoid constructing all model
@@ -2632,7 +2764,12 @@ pub fn can_connect(p: &Project, kind: RelationKind, source: Id, target: Id) -> b
*transition_kind = TransitionKind::Local;
}
let mut issues = vec![];
validate_relation(p, &candidate, &mut issues);
validate_relation(
p,
&candidate,
&crate::topology_context::TopologyIndex::new(p),
&mut issues,
);
validate_relation_context(p, &candidate, &mut issues);
if kind == RelationKind::StateTransition {
for endpoint in [source, target] {
@@ -2662,7 +2799,12 @@ pub fn can_connect_boundary(p: &Project, source: Id, target: Id, oriented: bool)
},
};
let mut issues = vec![];
validate_relation(p, &candidate, &mut issues);
validate_relation(
p,
&candidate,
&crate::topology_context::TopologyIndex::new(p),
&mut issues,
);
validate_relation_context(p, &candidate, &mut issues);
!issues.iter().any(|i| i.severity == Severity::Error)
}
+23 -1
View File
@@ -1,7 +1,10 @@
//! Persisted view intent and pure visibility decisions over actual model identities.
//! Build `ViewIndex` once per accepted model revision. Preparing a view performs
//! set unions/reachability once; testing an edge never walks the model.
use crate::{Id, Kind, Layer, OccurrenceBody, Project, RelationKind, SharedBody};
use crate::{
ContextualLinkPurpose, Id, Kind, Layer, OccurrenceBody, Project, RelationBody, RelationKind,
SharedBody,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -401,6 +404,25 @@ impl BehaviorIndex {
_ => {}
}
}
let topology = crate::topology_context::TopologyIndex::new(p);
for relation in p.relations.values() {
if let RelationBody::ContextualLink {
owner,
source,
target,
purpose: purpose @ ContextualLinkPurpose::Exchange { exchange },
} = &relation.body
&& topology
.validate_link(*owner, source, target, purpose)
.is_ok()
{
let facts = index.facts.entry(*owner).or_default();
facts.exchanges.insert(*exchange);
if let Some(exchange) = p.relations.get(exchange) {
facts.work.extend([exchange.source(), exchange.target()]);
}
}
}
index
}
+1
View File
@@ -27,6 +27,7 @@ fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id {
fn exchange(id: Id) -> OccurrenceBody {
OccurrenceBody::Exchange {
operation: None,
exchange: Some(id),
sender: None,
receiver: None,