Index semantic containment and start boundary connections from component ports

This commit is contained in:
2026-09-05 22:38:47 +03:00
parent 3c87576ed8
commit 60131e610a
2 changed files with 544 additions and 0 deletions
+400
View File
@@ -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<Group, Vec<Id>>,
children: BTreeMap<Id, Vec<Id>>,
parents: BTreeMap<Id, Id>,
groups: BTreeMap<Id, Group>,
order: BTreeMap<Group, Vec<Row>>,
search: BTreeMap<Id, SearchText>,
}
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<Id> {
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<Group> {
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<Id>) -> Vec<Row> {
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>) -> 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<Row>) -> Vec<Id> {
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");
}
}
+144
View File
@@ -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();