//! 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::{ ContextualLinkPurpose, Id, Kind, Layer, OccurrenceBody, Project, RelationBody, RelationKind, SharedBody, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ExchangeFamily { Work, Participant, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum EdgeKind { Relation(RelationKind), Trace, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum RelationKinds { #[default] All, Only(Vec), } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum CategorySelection { #[default] All, Any(Vec), } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum ContextSelection { #[default] All, Any(Vec), } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum Coverage { #[default] All, InBehavior, OutsideBehavior, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum ExchangeLabels { #[default] Name, Items, NameAndItems, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ViewOptions { pub hidden_edges: Vec, pub relation_kinds: RelationKinds, pub work_categories: CategorySelection, pub participant_categories: CategorySelection, pub coverage: Coverage, pub work_contexts: ContextSelection, pub exchange_labels: ExchangeLabels, } impl ViewOptions { pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) { for id in &mut self.hidden_edges { visit(id); } for selection in [&mut self.work_categories, &mut self.participant_categories] { if let CategorySelection::Any(ids) = selection { for id in ids { visit(id); } } } if let ContextSelection::Any(ids) = &mut self.work_contexts { for id in ids { visit(id); } } } /// Removing the last selected identity retains the explicit empty selector. pub fn retain_references(&mut self, mut keep: impl FnMut(&Id) -> bool) { self.hidden_edges.retain(&mut keep); for selection in [&mut self.work_categories, &mut self.participant_categories] { if let CategorySelection::Any(ids) = selection { ids.retain(&mut keep); } } if let ContextSelection::Any(ids) = &mut self.work_contexts { ids.retain(keep); } } } /// Stable field names allow core/source adapters to locate the offending option. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OptionError { pub field: &'static str, pub reference: Option, pub message: String, } pub fn exchange_family(kind: RelationKind) -> Option { match kind { RelationKind::Interaction | RelationKind::FunctionalExchange => Some(ExchangeFamily::Work), RelationKind::BoundaryExchange | RelationKind::CommunicationMean => { Some(ExchangeFamily::Participant) } _ => None, } } fn category_member(p: &Project, layer: Layer, family: ExchangeFamily, id: Id) -> bool { p.relations.get(&id).is_some_and(|r| { p.layer_of(id) == Some(layer) && matches!( (family, layer, r.kind()), (ExchangeFamily::Work, Layer::Oa, RelationKind::Interaction) | ( ExchangeFamily::Work, Layer::Sa, RelationKind::FunctionalExchange ) | ( ExchangeFamily::Participant, Layer::Oa, RelationKind::CommunicationMean ) | ( ExchangeFamily::Participant, _, RelationKind::BoundaryExchange ) ) }) } fn context_kind(kind: Kind) -> bool { matches!(kind, Kind::Process | Kind::FunctionalChain | Kind::Scenario) } fn edge_layers(p: &Project, id: Id) -> Vec { if p.relations.contains_key(&id) { p.layer_of(id).into_iter().collect() } else if p.traces.get(&id).is_some_and(|trace| { p.layer_of(trace.oa) == Some(Layer::Oa) && p.layer_of(trace.sa) == Some(Layer::Sa) }) { vec![Layer::Oa, Layer::Sa] } else { vec![] } } fn error(field: &'static str, reference: Option, message: &str) -> OptionError { OptionError { field, reference, message: message.into(), } } fn distinct_ids(field: &'static str, ids: &[Id], out: &mut Vec) { let mut seen = BTreeSet::new(); for id in ids { if !seen.insert(*id) { out.push(error( field, Some(*id), "Each identity must occur only once.", )); } } } pub fn validate_category( p: &Project, layer: Layer, parent: Option, family: ExchangeFamily, exchanges: &[Id], ) -> Vec { let mut out = vec![]; if parent.is_some() { out.push(error( "parent", parent, "Exchange categories belong at the layer root.", )); } distinct_ids("exchanges", exchanges, &mut out); for id in exchanges { if !category_member(p, layer, family, *id) { out.push(error( "exchanges", Some(*id), "Choose an existing exchange of this category's family and layer.", )); } } out } pub fn validate_options(p: &Project, layer: Layer, options: &ViewOptions) -> Vec { validate_options_in(p, Some(layer), options) } /// Traceability views admit references from either architecture layer. pub fn validate_options_cross_layer(p: &Project, options: &ViewOptions) -> Vec { validate_options_in(p, None, options) } fn validate_options_in( p: &Project, layer: Option, options: &ViewOptions, ) -> Vec { let mut out = vec![]; distinct_ids("hidden_edges", &options.hidden_edges, &mut out); for id in &options.hidden_edges { let layers = edge_layers(p, *id); if layers.is_empty() || layer.is_some_and(|layer| !layers.contains(&layer)) { out.push(error( "hidden_edges", Some(*id), "Choose an existing relationship or applicable trace in this view's layer.", )); } } if let RelationKinds::Only(kinds) = &options.relation_kinds { for (index, kind) in kinds.iter().enumerate() { if kinds[..index].contains(kind) { out.push(error( "relation_kinds", None, "Each relationship-line kind must occur only once.", )); } } } for (field, selection, family) in [ ( "work_categories", &options.work_categories, ExchangeFamily::Work, ), ( "participant_categories", &options.participant_categories, ExchangeFamily::Participant, ), ] { if let CategorySelection::Any(ids) = selection { distinct_ids(field, ids, &mut out); for id in ids { if !p.elements.get(id).is_some_and(|e| { layer.is_none_or(|layer| e.layer() == layer) && matches!(e.shared(), Some(SharedBody::ExchangeCategory { family: f, .. }) if *f == family) }) { out.push(error(field, Some(*id), "Choose a category of this selector's family and layer.")); } } } } if let ContextSelection::Any(ids) = &options.work_contexts { distinct_ids("work_contexts", ids, &mut out); for id in ids { if !p.elements.get(id).is_some_and(|e| { layer.is_none_or(|layer| e.layer() == layer) && context_kind(e.kind()) }) { out.push(error( "work_contexts", Some(*id), "Choose a Process, Functional chain or Scenario in this view's layer.", )); } } } out } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct BehaviorEvidence { pub work: BTreeSet, pub exchanges: BTreeSet, } /// Explicit inclusion/reuse graph, independent of semantic-parent descendants. #[derive(Debug, Clone, Default)] struct BehaviorIndex { roots: BTreeMap, next: BTreeMap>, facts: BTreeMap, } impl BehaviorIndex { fn new(p: &Project) -> Self { let mut index = Self::default(); for element in p.elements.values() { let id = element.id; if context_kind(element.kind()) { index.roots.insert(id, element.layer()); } let next = index.next.entry(id).or_default(); if let Some(steps) = element.steps() { next.extend( steps .iter() .filter(|step| { p.elements.get(step).is_some_and(|child| { child.kind() == Kind::Occurrence && child.parent == Some(id) && child.layer() == element.layer() }) }) .copied(), ); } let Some(SharedBody::Occurrence { body, .. }) = element.shared() else { continue; }; match body { OccurrenceBody::Action { action: Some(work), .. } => { if p.elements .get(work) .is_some_and(|e| e.kind().is_work() && e.layer() == element.layer()) { index.facts.entry(id).or_default().work.insert(*work); } } OccurrenceBody::Exchange { exchange: Some(exchange), .. } => { if let Some(r) = p.relations.get(exchange) && exchange_family(r.kind()) == Some(ExchangeFamily::Work) && [r.source(), r.target()].iter().all(|id| { p.elements .get(id) .is_some_and(|e| e.kind().is_work() && e.layer() == element.layer()) }) { let facts = index.facts.entry(id).or_default(); facts.exchanges.insert(*exchange); facts.work.extend([r.source(), r.target()]); } } OccurrenceBody::Fragment { branches, .. } => { next.extend( branches .iter() .filter(|branch| { p.elements.get(branch).is_some_and(|child| { child.parent == Some(id) && child.layer() == element.layer() && matches!( child.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Branch { .. }, .. }) ) }) }) .copied(), ); } OccurrenceBody::SequenceReference { sequence: Some(sequence), } => { if let Some(owner) = element.parent.and_then(|id| p.elements.get(&id)) && matches!(owner.kind(), Kind::Process | Kind::FunctionalChain) && p.elements.get(sequence).is_some_and(|target| { target.kind() == owner.kind() && target.layer() == element.layer() }) { next.push(*sequence); } } OccurrenceBody::ScenarioUse { scenario: Some(scenario), .. } if p.elements.get(scenario).is_some_and(|target| { target.kind() == Kind::Scenario && target.layer() == element.layer() }) => { next.push(*scenario); } _ => {} } } 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 } fn evidence(&self, roots: impl IntoIterator) -> BehaviorEvidence { let mut pending: Vec<_> = roots .into_iter() .filter(|id| self.roots.contains_key(id)) .collect(); let mut seen = BTreeSet::new(); let mut result = BehaviorEvidence::default(); while let Some(id) = pending.pop() { if !seen.insert(id) { continue; } if let Some(next) = self.next.get(&id) { pending.extend(next); } if let Some(facts) = self.facts.get(&id) { result.work.extend(&facts.work); result.exchanges.extend(&facts.exchanges); } } result } } pub fn behavior_evidence(p: &Project, roots: impl IntoIterator) -> BehaviorEvidence { BehaviorIndex::new(p).evidence(roots) } pub fn all_behavior_evidence(p: &Project) -> BehaviorEvidence { let index = BehaviorIndex::new(p); index.evidence(index.roots.keys().copied()) } #[derive(Debug, Clone)] struct EdgeFact { kind: EdgeKind, family: Option, layers: Vec, } #[derive(Debug, Clone)] struct CategoryFact { layer: Layer, family: ExchangeFamily, exchanges: BTreeSet, } #[derive(Debug, Clone)] pub struct ViewIndex { edges: BTreeMap, categories: BTreeMap, behavior: BehaviorIndex, global: BehaviorEvidence, } impl ViewIndex { pub fn new(p: &Project) -> Self { let behavior = BehaviorIndex::new(p); let global = behavior.evidence(behavior.roots.keys().copied()); let mut edges: BTreeMap<_, _> = p .relations .values() .map(|r| { ( r.id, EdgeFact { kind: EdgeKind::Relation(r.kind()), family: exchange_family(r.kind()), layers: edge_layers(p, r.id), }, ) }) .collect(); edges.extend(p.traces.keys().map(|id| { ( *id, EdgeFact { kind: EdgeKind::Trace, family: None, layers: edge_layers(p, *id), }, ) })); let categories = p .elements .values() .filter_map(|e| { let SharedBody::ExchangeCategory { family, exchanges } = e.shared()? else { return None; }; Some(( e.id, CategoryFact { layer: e.layer(), family: *family, exchanges: exchanges .iter() .filter(|id| category_member(p, e.layer(), *family, **id)) .copied() .collect(), }, )) }) .collect(); Self { edges, categories, behavior, global, } } pub fn all_evidence(&self) -> &BehaviorEvidence { &self.global } /// Options must pass `validate_options` before model admission. Malformed /// selectors are nevertheless fail-closed here, useful for draft previews. pub fn prepare(&self, layer: Layer, options: &ViewOptions) -> PreparedFilter<'_> { self.prepare_in(layer, options, false) } fn prepare_in( &self, layer: Layer, options: &ViewOptions, cross_layer: bool, ) -> PreparedFilter<'_> { let category_union = |selection: &CategorySelection, family| match selection { CategorySelection::All => None, CategorySelection::Any(ids) => Some( ids.iter() .filter_map(|id| self.categories.get(id)) .filter(|c| (cross_layer || c.layer == layer) && c.family == family) .flat_map(|c| c.exchanges.iter().copied()) .collect(), ), }; let contexts = match &options.work_contexts { ContextSelection::All => None, ContextSelection::Any(ids) => Some( self.behavior .evidence( ids.iter() .filter(|id| { self.behavior .roots .get(id) .is_some_and(|candidate| cross_layer || *candidate == layer) }) .copied(), ) .exchanges, ), }; PreparedFilter { index: self, layer, cross_layer, hidden: options.hidden_edges.iter().copied().collect(), kinds: options.relation_kinds.clone(), coverage: options.coverage, work_categories: category_union(&options.work_categories, ExchangeFamily::Work), participant_categories: category_union( &options.participant_categories, ExchangeFamily::Participant, ), contexts, } } /// Traceability diagrams admit edges, categories and contexts in both layers. pub fn prepare_cross_layer(&self, layer: Layer, options: &ViewOptions) -> PreparedFilter<'_> { self.prepare_in(layer, options, true) } } /// Projection reports why an actual edge cannot have a drawable endpoint pair. /// Filtering does not perform anchoring, folding, layout or placement insertion. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum EdgeAvailability { Visible, OutsideProjection, EndpointNotShown { source: bool, target: bool }, FoldedInternal, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum OmissionReason { ExplicitHide, RelationKind, Category(ExchangeFamily), Coverage, WorkContext, OutsideProjection, EndpointNotShown { source: bool, target: bool }, FoldedInternal, WrongLayer, UnknownEdge, } pub struct PreparedFilter<'a> { index: &'a ViewIndex, layer: Layer, cross_layer: bool, hidden: BTreeSet, kinds: RelationKinds, coverage: Coverage, work_categories: Option>, participant_categories: Option>, contexts: Option>, } impl PreparedFilter<'_> { pub fn includes(&self, edge: Id) -> bool { self.reasons(edge, EdgeAvailability::Visible).is_empty() } pub fn reasons(&self, edge: Id, availability: EdgeAvailability) -> Vec { let mut out = vec![]; if self.hidden.contains(&edge) { out.push(OmissionReason::ExplicitHide); } match availability { EdgeAvailability::Visible => {} EdgeAvailability::OutsideProjection => out.push(OmissionReason::OutsideProjection), EdgeAvailability::EndpointNotShown { source, target } => { out.push(OmissionReason::EndpointNotShown { source, target }) } EdgeAvailability::FoldedInternal => out.push(OmissionReason::FoldedInternal), } let Some(fact) = self.index.edges.get(&edge) else { out.push(OmissionReason::UnknownEdge); return out; }; if !fact.layers.contains(&self.layer) && !(self.cross_layer && !fact.layers.is_empty()) { out.push(OmissionReason::WrongLayer); } if let RelationKinds::Only(kinds) = &self.kinds && !kinds.contains(&fact.kind) { out.push(OmissionReason::RelationKind); } if let Some(family) = fact.family { let categories = match family { ExchangeFamily::Work => &self.work_categories, ExchangeFamily::Participant => &self.participant_categories, }; if categories.as_ref().is_some_and(|ids| !ids.contains(&edge)) { out.push(OmissionReason::Category(family)); } if family == ExchangeFamily::Work { let covered = self.index.global.exchanges.contains(&edge); if matches!(self.coverage, Coverage::InBehavior) && !covered || matches!(self.coverage, Coverage::OutsideBehavior) && covered { out.push(OmissionReason::Coverage); } if self .contexts .as_ref() .is_some_and(|ids| !ids.contains(&edge)) { out.push(OmissionReason::WorkContext); } } } out } } impl ExchangeLabels { pub fn label(self, p: &Project, edge: Id) -> String { let name = p.named(edge); let Some(relation) = p .relations .get(&edge) .filter(|r| exchange_family(r.kind()).is_some()) else { return name.into(); }; if self == Self::Name { return name.into(); } let items = if relation.body.items().is_empty() { "No items".into() } else { relation .body .items() .iter() .map(|id| p.named(*id)) .collect::>() .join(", ") }; if self == Self::Items { items } else { format!("{name} ยท {items}") } } } #[cfg(test)] #[path = "view_options_tests.rs"] mod tests;