Files
ARCADIA-Studio/crates/oa-core/src/view_options.rs
T
Codex e2ecfea69b
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Checkpoint source 7 workflows and in-progress diagram authoring redesign
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.
2026-09-10 10:06:55 -07:00

734 lines
24 KiB
Rust

//! 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<EdgeKind>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum CategorySelection {
#[default]
All,
Any(Vec<Id>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ContextSelection {
#[default]
All,
Any(Vec<Id>),
}
#[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<Id>,
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<Id>,
pub message: String,
}
pub fn exchange_family(kind: RelationKind) -> Option<ExchangeFamily> {
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<Layer> {
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<Id>, message: &str) -> OptionError {
OptionError {
field,
reference,
message: message.into(),
}
}
fn distinct_ids(field: &'static str, ids: &[Id], out: &mut Vec<OptionError>) {
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<Id>,
family: ExchangeFamily,
exchanges: &[Id],
) -> Vec<OptionError> {
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<OptionError> {
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<OptionError> {
validate_options_in(p, None, options)
}
fn validate_options_in(
p: &Project,
layer: Option<Layer>,
options: &ViewOptions,
) -> Vec<OptionError> {
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<Id>,
pub exchanges: BTreeSet<Id>,
}
/// Explicit inclusion/reuse graph, independent of semantic-parent descendants.
#[derive(Debug, Clone, Default)]
struct BehaviorIndex {
roots: BTreeMap<Id, Layer>,
next: BTreeMap<Id, Vec<Id>>,
facts: BTreeMap<Id, BehaviorEvidence>,
}
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<Item = Id>) -> 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<Item = Id>) -> 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<ExchangeFamily>,
layers: Vec<Layer>,
}
#[derive(Debug, Clone)]
struct CategoryFact {
layer: Layer,
family: ExchangeFamily,
exchanges: BTreeSet<Id>,
}
#[derive(Debug, Clone)]
pub struct ViewIndex {
edges: BTreeMap<Id, EdgeFact>,
categories: BTreeMap<Id, CategoryFact>,
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<Id>,
kinds: RelationKinds,
coverage: Coverage,
work_categories: Option<BTreeSet<Id>>,
participant_categories: Option<BTreeSet<Id>>,
contexts: Option<BTreeSet<Id>>,
}
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<OmissionReason> {
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::<Vec<_>>()
.join(", ")
};
if self == Self::Items {
items
} else {
format!("{name} · {items}")
}
}
}
#[cfg(test)]
#[path = "view_options_tests.rs"]
mod tests;