From 60131e610ac11243d91542a4bb4dc9b0eb90f059 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sat, 5 Sep 2026 22:38:47 +0300 Subject: [PATCH] Index semantic containment and start boundary connections from component ports --- crates/oa-desktop/src/explorer.rs | 400 ++++++++++++++++++++++++++++++ crates/oa-desktop/src/workflow.rs | 144 +++++++++++ 2 files changed, 544 insertions(+) create mode 100644 crates/oa-desktop/src/explorer.rs diff --git a/crates/oa-desktop/src/explorer.rs b/crates/oa-desktop/src/explorer.rs new file mode 100644 index 00000000..1e03afd6 --- /dev/null +++ b/crates/oa-desktop/src/explorer.rs @@ -0,0 +1,400 @@ +//! A semantic containment index shared by the sidebar and focused model browser. +//! Groups organize roots only; allocations never become containment. Traversals +//! are iterative and search keeps the ancestry of each result visible. +use oa_core::{Id, Kind, Layer, Project}; +use std::collections::{BTreeMap, BTreeSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Group { + Participants, + Work, + MissionsCapabilities, + Behavior, + DataInterfaces, + Other, +} + +impl Group { + pub const ALL: [Self; 6] = [ + Self::Participants, + Self::Work, + Self::MissionsCapabilities, + Self::Behavior, + Self::DataInterfaces, + Self::Other, + ]; + + pub fn label(self, layer: Layer) -> &'static str { + match (self, layer) { + (Self::Participants, Layer::Oa) => "Entities, actors & roles", + (Self::Participants, Layer::Sa) => "System & actors", + (Self::Work, Layer::Oa) => "Operational activities", + (Self::Work, Layer::Sa) => "System functions", + (Self::MissionsCapabilities, _) => "Missions & capabilities", + (Self::Behavior, _) => "Behavior & scenarios", + (Self::DataInterfaces, _) => "Data & interfaces", + (Self::Other, _) => "Other elements", + } + } + + fn for_root(kind: Kind) -> Self { + match kind { + Kind::Entity | Kind::Actor | Kind::Role | Kind::System | Kind::SystemActor => { + Self::Participants + } + Kind::Activity | Kind::Function => Self::Work, + Kind::Capability + | Kind::OperationalMission + | Kind::SystemCapability + | Kind::Mission => Self::MissionsCapabilities, + Kind::Process + | Kind::FunctionalChain + | Kind::Scenario + | Kind::Lifeline + | Kind::Occurrence + | Kind::StateMachine + | Kind::Region + | Kind::Event => Self::Behavior, + Kind::ExchangeItem + | Kind::DataType + | Kind::DataField + | Kind::Interface + | Kind::Port => Self::DataInterfaces, + kind if kind.is_state() => Self::Behavior, + _ => Self::Other, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Row { + pub id: Id, + /// Actual containment depth, with no display-specific truncation. + pub depth: usize, + pub has_children: bool, + /// False only for an ancestor retained as context for a nonempty search. + pub matched: bool, +} + +struct SearchText { + name: String, + alias: String, + kind: String, +} + +pub struct Index { + /// Every group is present, even when it has no roots in this perspective. + pub roots: BTreeMap>, + children: BTreeMap>, + parents: BTreeMap, + groups: BTreeMap, + order: BTreeMap>, + search: BTreeMap, +} + +impl Index { + pub fn new(project: &Project, layer: Layer) -> Self { + let elements: BTreeMap<_, _> = project + .elements + .values() + .filter(|element| element.layer() == layer) + .map(|element| (element.id, element)) + .collect(); + let sort_keys: BTreeMap<_, _> = elements + .iter() + .map(|(&id, element)| (id, (element.name.to_lowercase(), element.alias.clone(), id))) + .collect(); + let mut index = Self { + roots: Group::ALL + .into_iter() + .map(|group| (group, vec![])) + .collect(), + children: BTreeMap::new(), + parents: BTreeMap::new(), + groups: BTreeMap::new(), + order: BTreeMap::new(), + search: elements + .iter() + .map(|(&id, element)| { + ( + id, + SearchText { + name: element.name.to_lowercase(), + alias: element.alias.to_lowercase(), + kind: element.kind().label().to_lowercase(), + }, + ) + }) + .collect(), + }; + for (&id, element) in &elements { + if let Some(parent) = element + .parent + .filter(|parent| elements.contains_key(parent)) + { + index.parents.insert(id, parent); + index.children.entry(parent).or_default().push(id); + } else { + index + .roots + .get_mut(&Group::for_root(element.kind())) + .unwrap() + .push(id); + } + } + for siblings in index.roots.values_mut().chain(index.children.values_mut()) { + siblings.sort_unstable_by(|left, right| sort_keys[left].cmp(&sort_keys[right])); + } + for group in Group::ALL { + let mut stack: Vec<_> = index.roots[&group] + .iter() + .rev() + .map(|&id| (id, 0usize)) + .collect(); + let mut order = vec![]; + while let Some((id, depth)) = stack.pop() { + // Accepted models are acyclic; also bound traversal of a damaged + // candidate so navigation itself cannot loop indefinitely. + if index.groups.insert(id, group).is_some() { + continue; + } + let children = index.children(id); + order.push(Row { + id, + depth, + has_children: !children.is_empty(), + matched: true, + }); + stack.extend(children.iter().rev().map(|&child| (child, depth + 1))); + } + index.order.insert(group, order); + } + index + } + + pub fn children(&self, id: Id) -> &[Id] { + self.children.get(&id).map_or(&[], Vec::as_slice) + } + + /// Breadcrumb identities in root-to-selection order, including selection. + pub fn ancestry(&self, id: Id) -> Vec { + if !self.search.contains_key(&id) { + return vec![]; + } + let mut result = vec![]; + let mut seen = BTreeSet::new(); + let mut current = Some(id); + while let Some(id) = current { + if !seen.insert(id) { + break; + } + result.push(id); + current = self.parents.get(&id).copied(); + } + result.reverse(); + result + } + + pub fn group(&self, id: Id) -> Option { + self.groups.get(&id).copied() + } + + /// Case-insensitive terms match names, aliases or kind labels. An `@term` + /// matches aliases only. Search reveals matching paths without changing the + /// user's collapsed state; unrelated descendants remain filtered out. + pub fn rows(&self, group: Group, query: &str, collapsed_ids: &BTreeSet) -> Vec { + let normalized = query.to_lowercase(); + let terms: Vec<_> = normalized.split_whitespace().collect(); + let order = &self.order[&group]; + if terms.is_empty() { + let mut rows = vec![]; + let mut hidden_below = None; + for &row in order { + if hidden_below.is_some_and(|depth| row.depth > depth) { + continue; + } + hidden_below = collapsed_ids.contains(&row.id).then_some(row.depth); + rows.push(row); + } + return rows; + } + let matched: BTreeSet<_> = order + .iter() + .filter(|row| { + let text = &self.search[&row.id]; + terms.iter().all(|term| { + if let Some(alias) = term.strip_prefix('@') { + text.alias.contains(alias) + } else { + text.name.contains(term) + || text.alias.contains(term) + || text.kind.contains(term) + } + }) + }) + .map(|row| row.id) + .collect(); + let mut visible = matched.clone(); + // Reverse preorder propagates context to all ancestors in one pass; + // walking every result's full path would be quadratic on deep models. + for row in order.iter().rev() { + if visible.contains(&row.id) + && let Some(parent) = self.parents.get(&row.id) + { + visible.insert(*parent); + } + } + order + .iter() + .filter(|row| visible.contains(&row.id)) + .map(|row| Row { + matched: matched.contains(&row.id), + ..*row + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn add(project: &mut Project, kind: Kind, name: &str, parent: Option) -> Id { + if kind == Kind::Port { + let id = Id::new_v4(); + let port = oa_core::make_port( + project, + id, + parent.unwrap(), + name, + oa_core::PortContract::Component { + kind: oa_core::ComponentPortKind::Flow, + orientation: oa_core::ComponentPortOrientation::Unset, + }, + ) + .unwrap(); + project.elements.insert(id, port); + return id; + } + project.add_element(kind, Layer::Sa, name, parent).unwrap() + } + + fn ids(rows: Vec) -> Vec { + rows.into_iter().map(|row| row.id).collect() + } + + #[test] + fn prefix_names_cannot_separate_a_parent_from_its_children() { + let mut project = Project::empty(); + let parent = add(&mut project, Kind::SystemActor, "Rescue", None); + let child = add(&mut project, Kind::Port, "Output", Some(parent)); + let other = add(&mut project, Kind::SystemActor, "Rescue archive", None); + let index = Index::new(&project, Layer::Sa); + assert_eq!( + ids(index.rows(Group::Participants, "", &BTreeSet::new())), + vec![parent, child, other] + ); + assert_eq!(index.children(parent), &[child]); + assert_eq!(index.ancestry(child), vec![parent, child]); + assert_eq!(index.group(child), Some(Group::Participants)); + assert!(index.roots[&Group::DataInterfaces].is_empty()); + } + + #[test] + fn deep_containment_and_search_keep_the_full_path_without_recursion() { + let mut project = Project::empty(); + let mut parent = None; + let mut chain = vec![]; + for depth in 0..260 { + let id = add( + &mut project, + Kind::Function, + &format!("Function {depth}"), + parent, + ); + parent = Some(id); + chain.push(id); + } + let index = Index::new(&project, Layer::Sa); + let rows = index.rows(Group::Work, "", &BTreeSet::new()); + assert_eq!(rows.len(), chain.len()); + assert_eq!(rows.last().unwrap().depth, 259); + assert_eq!(index.ancestry(*chain.last().unwrap()), chain); + let filtered = index.rows(Group::Work, "Function 259", &BTreeSet::from([chain[0]])); + assert_eq!(filtered.len(), chain.len()); + assert!(filtered.last().unwrap().matched); + assert!(filtered[..259].iter().all(|row| !row.matched)); + } + + #[test] + fn collapse_and_search_retain_context_without_changing_collapse_state() { + let mut project = Project::empty(); + let owner = add(&mut project, Kind::SystemActor, "Operator", None); + let port = add(&mut project, Kind::Port, "Telemetry", Some(owner)); + project.elements.get_mut(&port).unwrap().alias = "report_out".into(); + let unrelated = add(&mut project, Kind::Port, "Power", Some(owner)); + let index = Index::new(&project, Layer::Sa); + let collapsed = BTreeSet::from([owner]); + assert_eq!( + ids(index.rows(Group::Participants, "", &collapsed)), + vec![owner] + ); + let filtered = index.rows(Group::Participants, "@REPORT port", &collapsed); + assert_eq!(ids(filtered.clone()), vec![owner, port]); + assert!(!filtered[0].matched); + assert!(filtered[1].matched); + assert!(filtered[0].has_children); + assert!(!filtered[1].has_children); + assert!(!filtered.iter().any(|row| row.id == unrelated)); + assert_eq!( + ids(index.rows(Group::Participants, "", &collapsed)), + vec![owner] + ); + assert!( + index + .rows(Group::Participants, "missing", &collapsed) + .is_empty() + ); + } + + #[test] + fn equal_names_have_stable_alias_and_identity_tie_breakers() { + let mut project = Project::empty(); + for (value, alias) in [(3, "zulu"), (2, "alpha"), (1, "alpha")] { + let mut element = project + .make_element(Kind::Function, Layer::Sa, "Same", None) + .unwrap(); + element.id = Id::from_u128(value); + element.alias = alias.into(); + project.elements.insert(element.id, element); + } + let index = Index::new(&project, Layer::Sa); + assert_eq!( + index.roots[&Group::Work], + [Id::from_u128(1), Id::from_u128(2), Id::from_u128(3)] + ); + assert_eq!(index.roots, Index::new(&project, Layer::Sa).roots); + } + + #[test] + fn perspective_isolation_and_allocations_do_not_change_containment() { + let mut project = Project::empty(); + let actor = add(&mut project, Kind::SystemActor, "Operator", None); + let work = add(&mut project, Kind::Function, "Respond", None); + project.relate(oa_core::RelationKind::Allocation, "Performs", actor, work); + let oa = project + .add_element(Kind::Activity, Layer::Oa, "Respond", None) + .unwrap(); + let oa_index = Index::new(&project, Layer::Oa); + let sa_index = Index::new(&project, Layer::Sa); + assert_eq!(oa_index.roots[&Group::Work], vec![oa]); + assert_eq!(sa_index.roots[&Group::Work], vec![work]); + assert!(sa_index.children(actor).is_empty()); + assert_eq!(sa_index.ancestry(work), vec![work]); + assert_eq!(oa_index.group(work), None); + assert_eq!(sa_index.group(oa), None); + assert!(oa_index.ancestry(work).is_empty()); + assert_eq!(Group::Work.label(Layer::Oa), "Operational activities"); + assert_eq!(Group::Work.label(Layer::Sa), "System functions"); + } +} diff --git a/crates/oa-desktop/src/workflow.rs b/crates/oa-desktop/src/workflow.rs index a2d758eb..fad6b7d1 100644 --- a/crates/oa-desktop/src/workflow.rs +++ b/crates/oa-desktop/src/workflow.rs @@ -292,6 +292,17 @@ impl Workflow { }) } pub fn relationship(p: &Project, selected: Id, layer: Layer) -> Self { + if let Some(PortContract::Component { orientation, .. }) = + p.elements.get(&selected).and_then(port_contract) + { + return Self::relationship_kind( + p, + selected, + layer, + RelationKind::BoundaryExchange, + *orientation == ComponentPortOrientation::In, + ); + } let kind = match p.elements.get(&selected).map(oa_core::Element::kind) { Some(Kind::OperationalMission | Kind::Mission) => RelationKind::MissionCapability, Some(Kind::Capability | Kind::SystemCapability) => RelationKind::CapabilityInclude, @@ -1115,6 +1126,30 @@ impl Workflow { } } fn choice(p: &Project, id: Id) -> Choice { + if let Some(element) = p.elements.get(&id) + && let Some(contract) = port_contract(element) + { + let contract = match contract { + PortContract::FunctionInput { .. } => "Input".into(), + PortContract::FunctionOutput { .. } => "Output".into(), + PortContract::Component { + kind: ComponentPortKind::Standard, + .. + } => "Standard".into(), + PortContract::Component { orientation, .. } => format!("Flow {orientation:?}"), + PortContract::Legacy => "Legacy".into(), + }; + return Choice { + id, + label: format!( + "{} / {} · {} · @{}", + short(element.parent.map_or("Unowned", |owner| p.named(owner)), 18), + short(&element.name, 20), + contract, + element.alias + ), + }; + } Choice { id, label: format!( @@ -1128,6 +1163,115 @@ fn choice(p: &Project, id: Id) -> Choice { #[cfg(test)] mod port_authoring_tests { use super::*; + + #[test] + fn generic_component_port_connection_keeps_its_endpoint_and_direction() { + for (kind, orientation) in [ + (ComponentPortKind::Flow, ComponentPortOrientation::In), + (ComponentPortKind::Flow, ComponentPortOrientation::Out), + (ComponentPortKind::Flow, ComponentPortOrientation::InOut), + (ComponentPortKind::Flow, ComponentPortOrientation::Unset), + (ComponentPortKind::Standard, ComponentPortOrientation::Unset), + ] { + let mut p = Project::empty(); + let actor = p + .add_element(Kind::SystemActor, Layer::Sa, "Actor", None) + .unwrap(); + let system = p + .add_element(Kind::System, Layer::Sa, "System", None) + .unwrap(); + let incoming = orientation == ComponentPortOrientation::In; + let other_orientation = if kind == ComponentPortKind::Standard { + ComponentPortOrientation::Unset + } else if incoming { + ComponentPortOrientation::Out + } else { + ComponentPortOrientation::In + }; + let mut ports = vec![]; + for (owner, orientation) in [(actor, orientation), (system, other_orientation)] { + let id = Id::new_v4(); + let port = make_port( + &p, + id, + owner, + "Data", + PortContract::Component { kind, orientation }, + ) + .unwrap(); + p.elements.insert(id, port); + ports.push(id); + } + let before = p.clone(); + let mut workflow = Workflow::relationship(&p, ports[0], Layer::Sa); + let Workflow::Relation(draft) = &workflow else { + unreachable!() + }; + assert_eq!(draft.kind, RelationKind::BoundaryExchange); + if incoming { + assert!(draft.source.is_none()); + assert_eq!(draft.target.as_ref().unwrap().id, ports[0]); + } else { + assert_eq!(draft.source.as_ref().unwrap().id, ports[0]); + assert!(draft.target.is_none()); + } + assert_eq!(draft.oriented, kind == ComponentPortKind::Flow); + workflow + .update( + &p, + if incoming { + Event::Source(choice(&p, ports[1])) + } else { + Event::Target(choice(&p, ports[1])) + }, + ) + .unwrap(); + let accepted = workflow.update(&p, Event::Apply).unwrap().unwrap(); + assert_eq!(p, before, "The modal never mutates its input model"); + let (expected_source, expected_target) = if incoming { + (ports[1], ports[0]) + } else { + (ports[0], ports[1]) + }; + assert!(accepted.relations.values().any(|relation| matches!(relation.body, RelationBody::BoundaryExchange { source, target, oriented, .. } if source == expected_source && target == expected_target && oriented == (kind == ComponentPortKind::Flow)))); + } + } + + #[test] + fn duplicate_port_names_show_owner_contract_and_unique_alias() { + let mut p = Project::empty(); + let actor = p + .add_element(Kind::SystemActor, Layer::Sa, "Operator", None) + .unwrap(); + let system = p + .add_element(Kind::System, Layer::Sa, "Rescue", None) + .unwrap(); + let mut labels = BTreeSet::new(); + for owner in [actor, actor, system] { + let id = Id::new_v4(); + let port = make_port( + &p, + id, + owner, + "Data", + PortContract::Component { + kind: ComponentPortKind::Flow, + orientation: ComponentPortOrientation::Out, + }, + ) + .unwrap(); + p.elements.insert(id, port); + let label = choice(&p, id).label; + assert!(label.contains(&format!("{} / Data", p.named(owner)))); + assert!(label.contains("Flow Out")); + assert!(label.contains(&format!("@{}", p.elements[&id].alias))); + assert!( + labels.insert(label), + "Duplicate names must still identify distinct ports" + ); + } + } + #[test] fn boundary_picker_recomputes_for_unoriented_reverse_endpoints_without_relaxing_contracts() { let mut p = Project::empty();