Add exact semantic history resets and unordered member review

This commit is contained in:
2026-09-06 08:09:44 +03:00
parent 2c0f93839f
commit 843635ed26
7 changed files with 1505 additions and 29 deletions
+6 -1
View File
@@ -718,7 +718,12 @@ fn free_string(path: &str) -> bool {
// These are choices for the closed model, not a second validation boundary.
// A current reference is always retained, even while other draft fields make it
// incompatible. Applying the complete draft remains the core validator's job.
fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id) -> Vec<Choice> {
pub(crate) fn reference_choices(
draft: &Draft,
project: &Project,
path: &str,
current: Id,
) -> Vec<Choice> {
let identities = project.identities().into_iter().collect::<BTreeMap<_, _>>();
let compatible = reference_ids(draft, project, path);
let mut objects = compatible
+159 -13
View File
@@ -9,6 +9,8 @@ use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
mod constructors;
mod history;
mod keyed;
mod message_kind;
mod scalar;
mod view;
@@ -16,6 +18,8 @@ pub(crate) use view::summary;
#[cfg(test)]
mod constructor_tests;
#[cfg(test)]
mod keyed_tests;
#[cfg(test)]
mod message_kind_tests;
#[cfg(test)]
mod tests;
@@ -24,6 +28,8 @@ mod tests;
pub enum Tab {
Facts,
References,
Members,
History,
Impacts,
}
@@ -49,6 +55,16 @@ pub enum Event {
RemoveReference(Id),
AcceptImpact(usize, bool),
Preview,
Reset(FacetSelector, bool),
Presence(FacetSelector, bool),
MemberFamily(keyed::Family),
MemberOa(Option<Choice>),
MemberSa(Option<Choice>),
Literal(String),
LiteralSide(Layer, bool),
EditPair(FacetSelector),
StageMember,
DropStaged(FacetSelector),
}
pub struct State {
@@ -59,6 +75,15 @@ pub struct State {
pub selected: Option<FacetSelector>,
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
pub references: BTreeMap<Id, Id>,
pub resets: BTreeSet<FacetSelector>,
pub staged: BTreeSet<FacetSelector>,
pub presence: BTreeMap<FacetSelector, bool>,
pub member_family: keyed::Family,
pub member_oa: Option<Choice>,
pub member_sa: Option<Choice>,
pub literal: String,
pub literal_oa: bool,
pub literal_sa: bool,
pub draft: Option<Draft>,
// Each closed constructor has an independent draft, including nested kinds.
// Scalar and sibling drafts survive decision and constructor changes.
@@ -104,6 +129,15 @@ impl State {
selected: None,
decisions: BTreeMap::new(),
references: BTreeMap::new(),
resets: BTreeSet::new(),
staged: BTreeSet::new(),
presence: BTreeMap::new(),
member_family: keyed::Family::Literal,
member_oa: None,
member_sa: None,
literal: String::new(),
literal_oa: true,
literal_sa: true,
draft: None,
group_drafts: BTreeMap::new(),
plan: None,
@@ -132,6 +166,14 @@ impl State {
fn load_trace(&mut self, project: &Project) {
self.facts.clear();
self.decisions.clear();
self.resets.clear();
self.staged.clear();
self.presence.clear();
self.member_oa = None;
self.member_sa = None;
self.literal.clear();
self.literal_oa = true;
self.literal_sa = true;
self.selected = None;
self.accepted_impacts.clear();
self.references.clear();
@@ -154,6 +196,11 @@ impl State {
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
self.references.insert(trace.oa, trace.sa);
}
self.member_family = self
.member_families(project)
.first()
.copied()
.unwrap_or(keyed::Family::Literal);
self.refresh(project);
}
@@ -191,6 +238,17 @@ impl State {
{
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
} else {
if keyed::supported(&facet) {
let target_presence = self
.effective_trace(project)
.and_then(|t| capture_facet(project, t.oa, t.sa, &facet).ok())
.and_then(|f| scalar::value(&f, self.direction.target_layer()))
.and_then(|v| v.as_bool())
.unwrap_or(false);
self.presence
.entry(facet.clone())
.or_insert(target_presence);
}
self.decisions.insert(facet, decision);
Ok(())
}
@@ -274,6 +332,57 @@ impl State {
return;
}
Event::Preview => Ok(()),
Event::Reset(facet, reset) => self.reset_history(project, facet, reset),
Event::Presence(facet, present) => {
if keyed::supported(&facet)
&& self.decisions.get(&facet) == Some(&SemanticDecision::Candidate)
{
self.presence.insert(facet, present);
Ok(())
} else {
Err("Choose Set final value for this member before editing presence.".into())
}
}
Event::MemberFamily(family) => {
self.member_family = family;
replan = false;
Ok(())
}
Event::MemberOa(choice) => {
self.member_oa = choice;
replan = false;
Ok(())
}
Event::MemberSa(choice) => {
self.member_sa = choice;
replan = false;
Ok(())
}
Event::Literal(value) => {
self.literal = value;
replan = false;
Ok(())
}
Event::LiteralSide(layer, present) => {
if layer == Layer::Oa {
self.literal_oa = present;
} else {
self.literal_sa = present;
}
replan = false;
Ok(())
}
Event::EditPair(facet) => {
replan = false;
self.edit_pair(project, facet)
}
Event::StageMember => self.stage_member(project),
Event::DropStaged(facet) => {
if self.staged.remove(&facet) {
self.decisions.remove(&facet);
}
Ok(())
}
};
if let Err(error) = result {
self.error = Some(error);
@@ -322,6 +431,11 @@ impl State {
}
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
if keyed::supported(facet) {
return self
.effective_trace(project)
.is_some_and(|trace| capture_facet(project, trace.oa, trace.sa, facet).is_ok());
}
self.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.is_some()
@@ -345,7 +459,7 @@ impl State {
if !scalar::grouped(facet) {
return false;
}
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
let Some(trace) = self.effective_trace(project) else {
return false;
};
if trace
@@ -394,7 +508,7 @@ impl State {
decision: *decision,
})
.collect(),
reset: vec![],
reset: self.resets.iter().cloned().collect(),
});
request.accepted_impacts = self.accepted_impacts.clone();
if self
@@ -423,10 +537,7 @@ impl State {
/// controls. Incomplete inputs retain their stored shape only for controls;
/// request construction always requires every selected input to parse.
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
let trace = self
.trace
.and_then(|id| project.traces.get(&id))
.ok_or("Choose a trace.")?;
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
let (source, target) = if self.direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
} else {
@@ -462,6 +573,10 @@ impl State {
if *decision == SemanticDecision::KeepTarget {
continue;
}
if keyed::supported(facet) {
self.keyed_value(project, &trace, &mut final_value, facet, *decision)?;
continue;
}
let roots = constructors::roots(project, target, facet)
.ok_or("Selected property is not applicable to the target.")?;
let supplied = match decision {
@@ -480,7 +595,7 @@ impl State {
if self.can_acknowledge_constructor(project, facet) {
original.clone()
} else {
constructors::take_value(project, trace, self.direction, facet, &map)?
constructors::take_value(project, &trace, self.direction, facet, &map)?
}
}
SemanticDecision::TakeSource => {
@@ -531,15 +646,43 @@ impl State {
for draft in self.group_drafts.values_mut() {
draft.set_context(context.clone());
}
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
let Some(trace) = self.effective_trace(project) else {
self.error = Some(
"Several traces are available. Choose the correspondence and review history above."
.into(),
);
return;
};
match semantic_facets(project, trace, &self.references) {
Ok(facts) => self.facts = facts,
match semantic_facets(project, &trace, &self.references) {
Ok(mut facts) => {
// Explicit addresses survive catalog normalization, pairing changes and resets.
for selector in self.staged.iter().chain(self.decisions.keys()) {
if !facts.iter().any(|f| &f.selector == selector) {
match capture_facet(project, trace.oa, trace.sa, selector) {
Ok(current) => {
let previous = trace
.baseline
.as_ref()
.and_then(|b| b.semantic.as_ref())
.and_then(|b| {
b.facets.iter().find(|f| f.selector() == *selector)
});
facts.push(FacetDrift {
selector: selector.clone(),
drift: facet_drift(&current, previous),
current,
});
}
Err(error) => {
self.error = Some(error);
return;
}
}
}
}
facts.sort_by(|a, b| a.selector.cmp(&b.selector));
self.facts = facts;
}
Err(error) => {
self.error = Some(error);
return;
@@ -579,8 +722,11 @@ impl State {
}
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
if self.decisions.is_empty() {
return Err("Choose at least one property to review.".into());
if self.decisions.is_empty() && self.resets.is_empty() {
return Err(
"Choose at least one property to review or an exact history observation to reset."
.into(),
);
}
if let Some(error) = &self.error {
return Err(error.clone());
@@ -594,7 +740,7 @@ impl State {
}
pub fn can_apply(&self) -> bool {
!self.decisions.is_empty()
(!self.decisions.is_empty() || !self.resets.is_empty())
&& self.error.is_none()
&& self
.plan
@@ -0,0 +1,43 @@
use super::*;
pub(super) fn resettable(facet: &FacetSelector) -> bool {
!matches!(facet, FacetSelector::Artifact { .. })
}
impl State {
pub(super) fn effective_trace(&self, project: &Project) -> Option<Trace> {
let mut trace = project.traces.get(&self.trace?)?.clone();
if let Some(baseline) = trace.baseline.as_mut().and_then(|b| b.semantic.as_mut()) {
baseline
.facets
.retain(|f| !self.resets.contains(&f.selector()));
}
Some(trace)
}
pub(super) fn reset_history(
&mut self,
project: &Project,
facet: FacetSelector,
reset: bool,
) -> Result<(), String> {
if !reset && self.resets.remove(&facet) {
return Ok(());
}
let exists = self
.trace
.and_then(|id| project.traces.get(&id))
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == facet));
if !exists || !resettable(&facet) {
return Err("Choose an exact existing non-artifact observation to reset.".into());
}
if reset {
self.resets.insert(facet);
} else {
self.resets.remove(&facet);
}
Ok(())
}
}
@@ -0,0 +1,263 @@
//! Closed presence-only adapter. The core independently validates the full final record.
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Family {
Member(ReferenceCollection),
Literal,
}
impl std::fmt::Display for Family {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Member(ReferenceCollection::FunctionInputItems) => "Function input items",
Self::Member(ReferenceCollection::FunctionOutputItems) => "Function output items",
Self::Member(ReferenceCollection::SignalItems) => "Signal items",
Self::Member(ReferenceCollection::ExchangeItems) => "Exchange items",
Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers",
Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects",
Self::Member(ReferenceCollection::Constrained) => "Constrained identities",
Self::Literal => "Enumeration literals",
_ => "Ordered membership (read-only)",
})
}
}
pub(super) const FAMILIES: [Family; 8] = [
Family::Member(ReferenceCollection::FunctionInputItems),
Family::Member(ReferenceCollection::FunctionOutputItems),
Family::Member(ReferenceCollection::SignalItems),
Family::Member(ReferenceCollection::ExchangeItems),
Family::Member(ReferenceCollection::TransitionTriggers),
Family::Member(ReferenceCollection::TransitionEffects),
Family::Member(ReferenceCollection::Constrained),
Family::Literal,
];
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
let family = match facet {
FacetSelector::Member { collection, .. } => Family::Member(*collection),
FacetSelector::Literal(_) => Family::Literal,
_ => return None,
};
FAMILIES.contains(&family).then_some(family)
}
pub(super) fn supported(facet: &FacetSelector) -> bool {
family(facet).is_some()
}
// Fixed paths for the seven unordered collections and the exact enum literal set.
// They are selected by the typed facet; callers cannot supply arbitrary paths.
pub(super) fn path(value: &Value, family: Family) -> Option<String> {
let prefixes = ["/body/Oa/Shared", "/body/Sa/Shared"];
let tails: &[&str] = match family {
Family::Member(ReferenceCollection::FunctionInputItems) => {
&["Port/contract/FunctionInput/incoming_items"]
}
Family::Member(ReferenceCollection::FunctionOutputItems) => {
&["Port/contract/FunctionOutput/outgoing_items"]
}
Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"],
Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"],
Family::Literal => &["DataType/definition/Enumeration/literals"],
Family::Member(ReferenceCollection::ExchangeItems) => {
return ["Interaction", "FunctionalExchange", "BoundaryExchange"]
.iter()
.map(|kind| format!("/body/{kind}/items"))
.find(|p| value.pointer(p).is_some_and(Value::is_array));
}
Family::Member(ReferenceCollection::TransitionTriggers) => {
return value
.pointer("/body/StateTransition/triggers")
.is_some_and(Value::is_array)
.then(|| "/body/StateTransition/triggers".into());
}
Family::Member(ReferenceCollection::TransitionEffects) => {
return value
.pointer("/body/StateTransition/effects")
.is_some_and(Value::is_array)
.then(|| "/body/StateTransition/effects".into());
}
_ => return None,
};
prefixes
.iter()
.flat_map(|prefix| tails.iter().map(move |tail| format!("{prefix}/{tail}")))
.find(|p| value.pointer(p).is_some_and(Value::is_array))
}
fn key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
match facet {
FacetSelector::Member { keys, .. } => if layer == Layer::Oa { keys.oa } else { keys.sa }
.map(|id| serde_json::to_value(id).expect("Id")),
FacetSelector::Literal(keys) => if layer == Layer::Oa {
keys.oa.as_ref()
} else {
keys.sa.as_ref()
}
.map(|v| Value::String(v.clone())),
_ => None,
}
}
pub(super) fn overlap(a: &FacetSelector, b: &FacetSelector) -> bool {
if family(a).is_none() || family(a) != family(b) {
return false;
}
[Layer::Oa, Layer::Sa]
.into_iter()
.any(|layer| key(a, layer).is_some() && key(a, layer) == key(b, layer))
}
pub(super) fn project_presence(
value: &mut Value,
facet: &FacetSelector,
layer: Layer,
present: bool,
) -> Result<(), String> {
let family = family(facet).ok_or("This keyed family remains read-only.")?;
let key = key(facet, layer)
.ok_or("Choose the destination identity in Members before setting or taking presence.")?;
let path =
path(value, family).ok_or("This collection is not applicable to the destination kind.")?;
let list = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Destination collection is unavailable.")?;
if present {
if !list.contains(&key) {
list.push(key);
}
} else {
list.retain(|v| v != &key);
}
Ok(())
}
impl State {
pub(super) fn keyed_value(
&self,
project: &Project,
trace: &Trace,
value: &mut Value,
facet: &FacetSelector,
decision: SemanticDecision,
) -> Result<(), String> {
let present = match decision {
SemanticDecision::KeepTarget => return Ok(()),
SemanticDecision::TakeSource => {
// A missing source identity is a target-local row, not a removal.
if key(facet, self.direction.source_layer()).is_none() {
return Ok(());
}
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::value(&fact, self.direction.source_layer()).and_then(|v| v.as_bool()).ok_or("Source collection is not applicable; choose an explicit final presence or Keep.")?
}
SemanticDecision::Candidate => *self
.presence
.get(facet)
.ok_or("Choose final membership presence.")?,
};
project_presence(value, facet, self.direction.target_layer(), present)
}
pub(super) fn member_families(&self, project: &Project) -> Vec<Family> {
let Some(trace) = self.effective_trace(project) else {
return vec![];
};
let values: Vec<_> = [trace.oa, trace.sa]
.into_iter()
.filter_map(|id| Draft::from_project(project, id).map(|d| d.value))
.collect();
FAMILIES
.into_iter()
.filter(|family| values.iter().any(|value| path(value, *family).is_some()))
.collect()
}
pub(super) fn member_choices(&self, project: &Project, layer: Layer) -> Vec<Choice> {
let Some(trace) = self.effective_trace(project) else {
return vec![];
};
let id = if layer == Layer::Oa {
trace.oa
} else {
trace.sa
};
let Some(mut draft) = Draft::from_project(project, id) else {
return vec![];
};
if layer == self.direction.target_layer() {
draft.set_context(self.combined_value(project, true).ok());
}
let selected = if layer == Layer::Oa {
&self.member_oa
} else {
&self.member_sa
};
let Some(path) = path(&draft.context_value(), self.member_family) else {
return selected.iter().cloned().collect();
};
crate::forms::reference_choices(
&draft,
project,
&format!("{path}/0"),
selected.as_ref().map_or(Id::nil(), |c| c.id),
)
.into_iter()
.filter(|c| !c.id.is_nil())
.collect()
}
pub(super) fn edit_pair(
&mut self,
project: &Project,
facet: FacetSelector,
) -> Result<(), String> {
self.member_family = family(&facet).ok_or("This keyed family remains read-only.")?;
match facet {
FacetSelector::Member { keys, .. } => {
self.member_oa = keys.oa.map(|id| view::choice(project, id));
self.member_sa = keys.sa.map(|id| view::choice(project, id));
}
FacetSelector::Literal(keys) => {
self.literal_oa = keys.oa.is_some();
self.literal_sa = keys.sa.is_some();
self.literal = keys.oa.or(keys.sa).unwrap_or_default();
}
_ => unreachable!(),
}
self.tab = Tab::Members;
Ok(())
}
pub(super) fn stage_member(&mut self, project: &Project) -> Result<(), String> {
if !FAMILIES.contains(&self.member_family) {
return Err("This keyed family remains read-only.".into());
}
let facet = match self.member_family {
Family::Member(collection) => FacetSelector::Member {
collection,
keys: KeyPair {
oa: self.member_oa.as_ref().map(|c| c.id),
sa: self.member_sa.as_ref().map(|c| c.id),
},
},
Family::Literal => FacetSelector::Literal(KeyPair {
oa: self.literal_oa.then(|| self.literal.clone()),
sa: self.literal_sa.then(|| self.literal.clone()),
}),
};
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
capture_facet(project, trace.oa, trace.sa, &facet)?;
if let FacetSelector::Member {
keys:
KeyPair {
oa: Some(oa),
sa: Some(sa),
},
..
} = &facet
{
self.references.insert(*oa, *sa);
}
self.staged.insert(facet.clone());
self.selected = Some(facet);
self.tab = Tab::Facts;
Ok(())
}
}
@@ -0,0 +1,782 @@
use super::{tests::*, *};
use crate::{app::App, document::Document};
fn errors(s: &State) -> String {
s.error
.iter()
.chain(s.plan.iter().flat_map(|p| &p.conflicts))
.cloned()
.collect::<Vec<_>>()
.join("; ")
}
fn put(p: &mut Project, owner: Id, facet: &FacetSelector, present: bool) {
let layer = p.layer_of(owner).unwrap();
let mut value = Draft::from_project(p, owner).unwrap().value;
keyed::project_presence(&mut value, facet, layer, present).unwrap();
if let Some(element) = p.elements.get_mut(&owner) {
*element = serde_json::from_value(value).unwrap();
} else {
p.relations
.insert(owner, serde_json::from_value(value).unwrap());
}
}
fn contains(p: &Project, owner: Id, facet: &FacetSelector, oa: Id, sa: Id) -> bool {
scalar::value(
&capture_facet(p, oa, sa, facet).unwrap(),
p.layer_of(owner).unwrap(),
)
.unwrap()
.as_bool()
.unwrap()
}
fn history(p: &mut Project, trace: Id, selectors: &[FacetSelector]) {
let t = &p.traces[&trace];
let baseline = TraceBaseline {
oa: p.properties(t.oa).unwrap(),
sa: p.properties(t.sa).unwrap(),
semantic: Some(SemanticBaseline {
facets: selectors
.iter()
.map(|s| capture_facet(p, t.oa, t.sa, s).unwrap())
.collect(),
}),
};
p.traces.get_mut(&trace).unwrap().baseline = Some(baseline);
}
fn literal(name: &str) -> FacetSelector {
FacetSelector::Literal(KeyPair {
oa: Some(name.into()),
sa: Some(name.into()),
})
}
fn enum_pair() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let mut ids = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let id = p.add_element(Kind::DataType, layer, "Enum", None).unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Enumeration {
literals: vec!["retained".into()],
},
default: Some(DataValue::Enumeration("retained".into())),
unit: String::new(),
};
ids.push(id);
}
let trace = add_trace(&mut p, ids[0], ids[1], "Literal review");
(p, ids[0], ids[1], trace)
}
fn member_pair(collection: ReferenceCollection) -> (Project, Id, Id, Id, FacetSelector, [Id; 2]) {
let mut p = Project::empty();
let mut owners = vec![];
let mut keys = vec![];
let mut locals = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let work = if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let kind = match collection {
ReferenceCollection::TransitionTriggers => Kind::Event,
ReferenceCollection::TransitionEffects | ReferenceCollection::Constrained => work,
_ => Kind::ExchangeItem,
};
let key = p.add_element(kind, layer, "Mapped member", None).unwrap();
let local = p.add_element(kind, layer, "Local member", None).unwrap();
let owner = match collection {
ReferenceCollection::SignalItems => {
let id = p.add_element(Kind::Event, layer, "Signal", None).unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Event {
body: EventBody::Signal { items: vec![] },
};
id
}
ReferenceCollection::Constrained => p
.add_element(Kind::Constraint, layer, "Constraint", None)
.unwrap(),
ReferenceCollection::ExchangeItems => {
let a = p.add_element(work, layer, "From", None).unwrap();
let b = p.add_element(work, layer, "To", None).unwrap();
let id = p.relate(
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
"Exchange",
a,
b,
);
if let RelationBody::FunctionalExchange {
source_port,
target_port,
..
} = p.relations[&id].body
{
for port in [source_port, target_port] {
if let Some(SharedBody::Port { contract, .. }) =
p.elements.get_mut(&port).unwrap().shared_mut()
{
match contract {
PortContract::FunctionInput { incoming_items } => {
*incoming_items = vec![key, local]
}
PortContract::FunctionOutput { outgoing_items } => {
*outgoing_items = vec![key, local]
}
_ => unreachable!(),
}
}
}
}
id
}
ReferenceCollection::TransitionTriggers | ReferenceCollection::TransitionEffects => {
let machine = p
.add_element(Kind::StateMachine, layer, "Machine", None)
.unwrap();
let region = p
.add_element(Kind::Region, layer, "Region", Some(machine))
.unwrap();
let state = p
.add_element(Kind::State, layer, "State", Some(region))
.unwrap();
p.relate(RelationKind::StateTransition, "Transition", state, state)
}
ReferenceCollection::FunctionInputItems | ReferenceCollection::FunctionOutputItems => {
let parent = p.add_element(work, layer, "Owner", None).unwrap();
let id = Id::new_v4();
let contract = if layer == Layer::Oa {
PortContract::Legacy
} else if collection == ReferenceCollection::FunctionInputItems {
PortContract::FunctionInput {
incoming_items: vec![],
}
} else {
PortContract::FunctionOutput {
outgoing_items: vec![],
}
};
let mut port = ports::make_port(&p, id, parent, "Port", contract).unwrap();
if layer == Layer::Oa
&& let Some(SharedBody::Port { legacy, .. }) = port.shared_mut()
{
*legacy = Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
});
}
p.elements.insert(id, port);
id
}
_ => unreachable!(),
};
owners.push(owner);
keys.push(key);
locals.push(local);
}
let trace = add_trace(&mut p, owners[0], owners[1], "Membership review");
let facet = FacetSelector::Member {
collection,
keys: KeyPair {
oa: Some(keys[0]),
sa: Some(keys[1]),
},
};
(
p,
owners[0],
owners[1],
trace,
facet,
[locals[0], locals[1]],
)
}
fn stage(app: &mut App, facet: &FacetSelector) {
send(app, Event::EditPair(facet.clone()));
send(app, Event::StageMember);
assert_eq!(
state(app).selected.as_ref(),
Some(facet),
"{}",
errors(state(app))
);
}
#[test]
fn unordered_members_keep_take_candidate_preserve_other_rows_both_directions() {
for collection in [
ReferenceCollection::SignalItems,
ReferenceCollection::ExchangeItems,
ReferenceCollection::TransitionTriggers,
ReferenceCollection::TransitionEffects,
ReferenceCollection::Constrained,
] {
for reverse in [false, true] {
for decision in [
SemanticDecision::KeepTarget,
SemanticDecision::TakeSource,
SemanticDecision::Candidate,
] {
let (mut p, oa, sa, trace, facet, locals) = member_pair(collection);
let (source, target, local) = if reverse {
(sa, oa, locals[0])
} else {
(oa, sa, locals[1])
};
put(&mut p, source, &facet, true);
let local_facet = FacetSelector::Member {
collection,
keys: KeyPair {
oa: reverse.then_some(local),
sa: (!reverse).then_some(local),
},
};
put(&mut p, target, &local_facet, true);
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, source);
stage(&mut app, &facet);
decide(&mut app, facet.clone(), decision);
if decision == SemanticDecision::Candidate {
send(&mut app, Event::Presence(facet.clone(), true));
}
// A target-only Take is an explicit acknowledgment, never implicit removal.
decide(&mut app, local_facet.clone(), SemanticDecision::TakeSource);
assert!(
state(&app).can_apply(),
"{collection:?} reverse={reverse} {decision:?}: {}",
errors(state(&app))
);
let proposed = state(&app).proposed.clone().unwrap();
assert_eq!(app.document.bundle(), &before.bundle);
apply(&mut app);
assert!(app.workflow.is_none(), "{}", app.status);
assert_eq!(app.project(), &proposed);
assert_eq!(
contains(app.project(), target, &facet, oa, sa),
decision != SemanticDecision::KeepTarget
);
assert!(contains(app.project(), target, &local_facet, oa, sa));
assert!(app.project().elements.contains_key(&local));
assert_eq!(
app.project().traces[&trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets
.len(),
2
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
}
}
}
}
#[test]
fn function_port_members_use_existing_typed_choices_and_preserve_oa_legacy() {
for collection in [
ReferenceCollection::FunctionInputItems,
ReferenceCollection::FunctionOutputItems,
] {
let (p, oa, sa, _, facet, _) = member_pair(collection);
let FacetSelector::Member { keys, .. } = &facet else {
unreachable!()
};
let mut app = App::with_project(p);
start(&mut app, oa);
assert_eq!(
state(&app).member_families(app.project()),
vec![keyed::Family::Member(collection)]
);
send(&mut app, Event::EditPair(facet.clone()));
assert!(
state(&app)
.member_choices(app.project(), Layer::Sa)
.iter()
.any(|c| Some(c.id) == keys.sa)
);
send(&mut app, Event::StageMember);
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
send(&mut app, Event::Presence(facet.clone(), true));
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
apply(&mut app);
assert!(contains(app.project(), sa, &facet, oa, sa));
assert!(matches!(
app.project().elements[&oa].shared(),
Some(SharedBody::Port {
contract: PortContract::Legacy,
..
})
));
start(&mut app, sa);
stage(&mut app, &facet);
decide(&mut app, facet.clone(), SemanticDecision::KeepTarget);
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
send(&mut app, Event::Presence(facet, true));
assert!(
!state(&app).can_apply(),
"OA typed membership must remain inapplicable"
);
}
}
#[test]
fn exact_literal_new_rows_and_removals_commit_both_directions_without_default_loss() {
for reverse in [false, true] {
let (mut p, oa, sa, _) = enum_pair();
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
let incoming = literal(" New value ");
put(&mut p, source, &incoming, true);
let removed = literal("old");
put(&mut p, target, &removed, true);
let created = literal("new value");
let mut app = App::with_project(p);
start(&mut app, source);
for (facet, decision) in [
(&incoming, SemanticDecision::TakeSource),
(&removed, SemanticDecision::Candidate),
(&created, SemanticDecision::Candidate),
] {
stage(&mut app, facet);
decide(&mut app, facet.clone(), decision);
if decision == SemanticDecision::Candidate {
send(&mut app, Event::Presence(facet.clone(), facet == &created));
}
}
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
apply(&mut app);
assert!(contains(app.project(), target, &incoming, oa, sa));
assert!(contains(app.project(), target, &created, oa, sa));
assert!(!contains(app.project(), target, &removed, oa, sa));
assert!(
matches!(app.project().elements[&target].shared(),Some(SharedBody::DataType { default: Some(DataValue::Enumeration(value)), .. }) if value=="retained")
);
let reopened = Document::from_bundle(
app.document.bundle().clone(),
source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
}
}
#[test]
fn exact_reset_only_is_one_history_source_transaction_and_undo_redo_both_directions() {
for reverse in [false, true] {
let (mut p, oa, sa, trace) = enum_pair();
let reset = literal("retained");
let kept = FacetSelector::Header(HeaderFacet::Name);
history(&mut p, trace, &[reset.clone(), kept.clone()]);
let previous = p.traces[&trace].baseline.clone().unwrap();
let mut app = App::with_project(p);
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Exact review rationale survives reset.\n");
app.document.apply_bundle(bundle).unwrap();
let before = app.document.snapshot();
let revision = app.document.revision();
start(&mut app, if reverse { sa } else { oa });
send(&mut app, Event::Reset(reset.clone(), true));
assert!(state(&app).decisions.is_empty());
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
assert_eq!(
state(&app)
.facts
.iter()
.find(|f| f.selector == reset)
.unwrap()
.drift,
Drift::Unreviewed
);
send(&mut app, Event::Reset(reset.clone(), false));
assert!(!state(&app).can_apply());
assert_eq!(
state(&app)
.facts
.iter()
.find(|f| f.selector == reset)
.unwrap()
.drift,
Drift::Unchanged
);
send(&mut app, Event::Reset(reset, true));
apply(&mut app);
assert_eq!(app.document.revision(), revision + 1);
assert_eq!(app.project().elements, before.decoded.project.elements);
let after = &app.project().traces[&trace].baseline.as_ref().unwrap();
assert_eq!(after.oa, previous.oa);
assert_eq!(after.sa, previous.sa);
assert_eq!(
after
.semantic
.as_ref()
.unwrap()
.facets
.iter()
.map(FacetReview::selector)
.collect::<Vec<_>>(),
vec![kept]
);
assert!(
app.document.bundle().files[&app.document.bundle().entry]
.contains("// Exact review rationale survives reset.")
);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted.bundle);
}
}
#[test]
fn historical_one_sided_pair_migration_requires_exact_reset_and_explicit_overlap_resolution() {
for reverse in [false, true] {
let (mut p, oa, sa, trace, paired, _) = member_pair(ReferenceCollection::SignalItems);
let FacetSelector::Member { collection, keys } = &paired else {
unreachable!()
};
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
let previous = FacetSelector::Member {
collection: *collection,
keys: KeyPair {
oa: if reverse { None } else { keys.oa },
sa: if reverse { keys.sa } else { None },
},
};
put(&mut p, source, &paired, true);
history(&mut p, trace, std::slice::from_ref(&previous));
let mut app = App::with_project(p);
start(&mut app, source);
decide(&mut app, previous.clone(), SemanticDecision::KeepTarget);
stage(&mut app, &paired);
decide(&mut app, paired.clone(), SemanticDecision::Candidate);
send(&mut app, Event::Presence(paired.clone(), true));
assert!(!state(&app).can_apply());
assert_eq!(state(&app).decisions.len(), 2);
send(&mut app, Event::Decide(previous.clone(), None));
assert!(
!state(&app).can_apply(),
"Historic pair cannot silently migrate"
);
send(&mut app, Event::Reset(previous.clone(), true));
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
send(&mut app, Event::Reset(previous, false));
assert!(!state(&app).can_apply());
assert!(state(&app).presence[&paired]);
let old = state(&app).resets.clone();
assert!(old.is_empty());
let historic = app.project().traces[&trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets[0]
.selector();
send(&mut app, Event::Reset(historic, true));
apply(&mut app);
assert!(contains(app.project(), target, &paired, oa, sa));
assert_eq!(
app.project().traces[&trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets[0]
.selector(),
paired
);
}
}
#[test]
fn reset_precedes_equal_constructor_candidate_and_preserves_independent_literal_draft() {
for reverse in [false, true] {
let (mut p, oa, sa, trace) = enum_pair();
let ctor = FacetSelector::Data(DataFacet::Constructor);
history(&mut p, trace, std::slice::from_ref(&ctor));
let mut app = App::with_project(p);
start(&mut app, if reverse { sa } else { oa });
let created = literal("separate");
stage(&mut app, &created);
decide(&mut app, created.clone(), SemanticDecision::Candidate);
send(&mut app, Event::Presence(created.clone(), true));
send(&mut app, Event::Reset(ctor.clone(), true));
decide(&mut app, ctor.clone(), SemanticDecision::Candidate);
assert!(state(&app).can_acknowledge_constructor(app.project(), &ctor));
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
let reviewed = &state(&app).plan.as_ref().unwrap().reviews[0];
assert_eq!(
reviewed
.acknowledged
.iter()
.cloned()
.collect::<BTreeSet<_>>(),
BTreeSet::from([ctor.clone(), created.clone()])
);
assert!(reviewed.retired.is_empty());
send(&mut app, Event::Reset(ctor.clone(), false));
assert!(!state(&app).can_apply());
assert!(state(&app).presence[&created]);
send(&mut app, Event::Reset(ctor, true));
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
}
}
#[test]
fn invalid_literal_removal_and_unselected_scalar_buffers_are_retained_and_repairable() {
let (p, oa, _, _) = enum_pair();
let mut app = App::with_project(p);
start(&mut app, oa);
let unit = FacetSelector::Data(DataFacet::Unit);
decide(&mut app, unit.clone(), SemanticDecision::Candidate);
send(
&mut app,
Event::Input(
"/body/Sa/Shared/DataType/unit".into(),
" untouched unit ".into(),
),
);
decide(&mut app, unit.clone(), SemanticDecision::KeepTarget);
let retained = literal("retained");
decide(&mut app, retained.clone(), SemanticDecision::Candidate);
send(&mut app, Event::Presence(retained.clone(), false));
assert!(
!state(&app).can_apply(),
"Existing default must prevent removal"
);
assert!(!state(&app).presence[&retained]);
send(&mut app, Event::Presence(retained.clone(), true));
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
assert_eq!(
state(&app).draft.as_ref().unwrap().inputs["/body/Sa/Shared/DataType/unit"].raw,
" untouched unit "
);
decide(&mut app, unit, SemanticDecision::Candidate);
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
}
#[test]
fn unsupported_independent_families_and_missing_identities_do_not_gain_edit_authority() {
let (p, oa, _, _) = enum_pair();
let mut app = App::with_project(p);
start(&mut app, oa);
let unsupported = FacetSelector::Member {
collection: ReferenceCollection::Steps,
keys: KeyPair {
oa: Some(oa),
sa: None,
},
};
decide(&mut app, unsupported.clone(), SemanticDecision::Candidate);
assert!(!state(&app).decisions.contains_key(&unsupported));
send(&mut app, Event::MemberFamily(keyed::Family::Literal));
send(&mut app, Event::Literal("unpaired".into()));
send(&mut app, Event::LiteralSide(Layer::Sa, false));
send(&mut app, Event::StageMember);
let facet = state(&app).selected.clone().unwrap();
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
assert!(!state(&app).can_apply());
send(&mut app, Event::LiteralSide(Layer::Oa, false));
send(&mut app, Event::StageMember);
assert!(state(&app).error.is_some());
assert_eq!(
state(&app).decisions.get(&facet),
Some(&SemanticDecision::Candidate)
);
}
#[test]
fn deleted_member_source_observation_removes_only_mapped_membership_both_directions() {
for reverse in [false, true] {
let (mut p, oa, sa, trace, facet, locals) = member_pair(ReferenceCollection::SignalItems);
let FacetSelector::Member { keys, .. } = &facet else {
unreachable!()
};
let (source, target, deleted, local) = if reverse {
(sa, oa, keys.sa.unwrap(), locals[0])
} else {
(oa, sa, keys.oa.unwrap(), locals[1])
};
let target_only = FacetSelector::Member {
collection: ReferenceCollection::SignalItems,
keys: KeyPair {
oa: reverse.then_some(local),
sa: (!reverse).then_some(local),
},
};
put(&mut p, source, &facet, true);
put(&mut p, target, &facet, true);
put(&mut p, target, &target_only, true);
history(&mut p, trace, std::slice::from_ref(&facet));
p = prepare_command(&p, &Command::Delete(deleted))
.unwrap()
.project;
let mut app = App::with_project(p);
start(&mut app, source);
stage(&mut app, &facet);
decide(&mut app, facet.clone(), SemanticDecision::TakeSource);
// Force mixed final-record composition, as well as the core Take projection.
let name = FacetSelector::Header(HeaderFacet::Name);
decide(&mut app, name, SemanticDecision::Candidate);
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
apply(&mut app);
assert!(!contains(app.project(), target, &facet, oa, sa));
assert!(contains(app.project(), target, &target_only, oa, sa));
let retained = if reverse {
keys.oa.unwrap()
} else {
keys.sa.unwrap()
};
assert!(app.project().elements.contains_key(&retained));
}
}
#[test]
fn stale_or_source_failed_reset_and_membership_apply_retain_exact_choices() {
let (mut p, oa, _, trace) = enum_pair();
let reset = FacetSelector::Header(HeaderFacet::Name);
history(&mut p, trace, std::slice::from_ref(&reset));
let mut app = App::with_project(p.clone());
start(&mut app, oa);
send(&mut app, Event::Reset(reset.clone(), true));
app.document
.execute(Command::Rename(oa, "Changed after preview".into()))
.unwrap();
let before = app.document.snapshot();
apply(&mut app);
assert!(state(&app).error.as_ref().unwrap().contains("stale"));
assert!(state(&app).resets.contains(&reset));
assert_eq!(app.document.bundle(), &before.bundle);
let mut app = App::with_project(p);
let bundle = app.document.bundle().clone();
let mut limits = source_project::ProjectLimits::default();
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 10;
app.document = Document::from_bundle(bundle, limits).unwrap();
start(&mut app, oa);
send(&mut app, Event::Reset(reset.clone(), true));
let added = literal(&" exact long literal ".repeat(100));
stage(&mut app, &added);
decide(&mut app, added.clone(), SemanticDecision::Candidate);
send(&mut app, Event::Presence(added.clone(), true));
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
let before = app.document.snapshot();
apply(&mut app);
assert!(state(&app).error.is_some());
assert!(state(&app).resets.contains(&reset));
assert_eq!(
state(&app).decisions.get(&added),
Some(&SemanticDecision::Candidate)
);
assert!(state(&app).presence[&added]);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn member_identity_labels_and_new_tabs_preserve_exact_names_and_deleted_keys() {
let (mut p, oa, _, trace, facet, _) = member_pair(ReferenceCollection::SignalItems);
let FacetSelector::Member { collection, keys } = &facet else {
unreachable!()
};
p.elements.get_mut(&keys.oa.unwrap()).unwrap().name = "Absent intentionally in name".into();
let name = view::title(&p, &facet);
assert!(name.contains("Absent intentionally in name"));
let deleted1 = Id::new_v4();
let deleted2 = Id::new_v4();
let old1 = FacetSelector::Member {
collection: *collection,
keys: KeyPair {
oa: Some(deleted1),
sa: None,
},
};
let old2 = FacetSelector::Member {
collection: *collection,
keys: KeyPair {
oa: Some(deleted2),
sa: None,
},
};
history(&mut p, trace, &[old1.clone(), old2.clone()]);
assert!(view::title(&p, &old1).contains(&deleted1.to_string()));
assert_ne!(view::title(&p, &old1), view::title(&p, &old2));
assert_ne!(
view::title(&p, &literal("Absent")),
view::title(
&p,
&FacetSelector::Literal(KeyPair {
oa: None,
sa: Some("Absent".into())
})
)
);
let mut app = App::with_project(p);
start(&mut app, oa);
for tab in [Tab::History, Tab::Members, Tab::Facts] {
send(&mut app, Event::Tab(tab));
let _ = state(&app).view(app.project());
}
send(&mut app, Event::Reset(old1.clone(), true));
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
assert!(!state(&app).facts.iter().any(|f| f.selector == old1));
assert!(state(&app).facts.iter().any(|f| f.selector == old2));
}
#[test]
fn staging_removal_and_obsolete_reset_recovery_keep_other_decisions_and_raw_presence() {
let (mut p, oa, _, trace) = enum_pair();
let reset = FacetSelector::Header(HeaderFacet::Name);
history(&mut p, trace, std::slice::from_ref(&reset));
let mut app = App::with_project(p.clone());
start(&mut app, oa);
send(&mut app, Event::Reset(reset.clone(), true));
let added = literal("staged draft");
stage(&mut app, &added);
decide(&mut app, added.clone(), SemanticDecision::Candidate);
send(&mut app, Event::Presence(added.clone(), true));
send(&mut app, Event::DropStaged(added.clone()));
assert!(state(&app).decisions.is_empty());
assert!(!state(&app).staged.contains(&added));
assert!(state(&app).presence[&added]);
assert!(state(&app).can_apply());
stage(&mut app, &added);
decide(&mut app, added.clone(), SemanticDecision::Candidate);
assert!(state(&app).presence[&added]);
p.traces
.get_mut(&trace)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic
.as_mut()
.unwrap()
.facets
.clear();
let crate::workflow::Workflow::Semantic(s) = app.workflow.as_mut().unwrap() else {
unreachable!()
};
s.refresh(&p);
assert!(!s.can_apply());
s.tab = Tab::History;
let _ = s.view(&p);
s.update(&p, Event::Reset(reset, false));
assert!(s.can_apply(), "{}", errors(s));
assert_eq!(s.decisions.get(&added), Some(&SemanticDecision::Candidate));
}
+188 -15
View File
@@ -71,27 +71,37 @@ pub(super) fn group(facet: &FacetSelector) -> &'static str {
}
}
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
let keys = |keys: &KeyPair<Id>| {
let keys = |keys: &KeyPair<Id>, missing: &str| {
format!(
"{} ↔ {}",
keys.oa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into()),
.unwrap_or_else(|| missing.into()),
keys.sa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into())
.unwrap_or_else(|| missing.into())
)
};
match facet {
FacetSelector::Member {
collection,
keys: pair,
} => format!("{} · {}", words(&format!("{collection:?}")), keys(pair)),
} => format!(
"{} · {}",
words(&format!("{collection:?}")),
keys(pair, "No identity")
),
FacetSelector::Order(collection) => format!("{} order", words(&format!("{collection:?}"))),
FacetSelector::Literal(pair) => format!(
"Literal · {} ↔ {}",
pair.oa.as_deref().unwrap_or("Absent"),
pair.sa.as_deref().unwrap_or("Absent")
pair.oa
.as_ref()
.map(|s| format!("{s:?}"))
.unwrap_or_else(|| "No identity".into()),
pair.sa
.as_ref()
.map(|s| format!("{s:?}"))
.unwrap_or_else(|| "No identity".into())
),
FacetSelector::Annotation(pair) => format!(
"Message value · {} ↔ {}",
@@ -104,11 +114,17 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
.map(|key| annotation_title(project, key))
.unwrap_or_else(|| "Absent".into())
),
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair)),
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
FacetSelector::Incident { keys: pair, end } => format!("Incident {end:?} · {}", keys(pair)),
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair, "Absent")),
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair, "Absent")),
FacetSelector::Incident { keys: pair, end } => {
format!("Incident {end:?} · {}", keys(pair, "Absent"))
}
FacetSelector::Artifact { keys: pair, facet } => {
format!("Artifact {} · {}", words(&format!("{facet:?}")), keys(pair))
format!(
"Artifact {} · {}",
words(&format!("{facet:?}")),
keys(pair, "Absent")
)
}
_ => {
let json = serde_json::to_value(facet).expect("Typed selector");
@@ -199,6 +215,12 @@ fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> Stri
let value = scalar::observation(fact, layer);
if value.as_str() == Some("NotApplicable") {
"Not applicable".into()
} else if keyed::supported(&fact.selector()) {
if value["Value"].as_bool() == Some(true) {
"Present".into()
} else {
"Absent".into()
}
} else {
formatted(project, &value["Value"], &scalar::references(fact))
}
@@ -237,11 +259,13 @@ impl State {
action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })),
].spacing(8),
text("Changing trace or direction starts a new review draft. Other pending edits remain separate.").size(10).color(style::MUTED),
row![action("Properties", Event::Tab(Tab::Facts)), action(format!("Reference pairs ({})", self.references.len()), Event::Tab(Tab::References)), action(format!("Additional changes ({})", self.plan.as_ref().map_or(0, |p| p.impacts.len())), Event::Tab(Tab::Impacts)), Space::new().width(Fill), action("Refresh preview", Event::Preview)].spacing(6),
row![action("Properties", Event::Tab(Tab::Facts)), action("Members / literals", Event::Tab(Tab::Members)), action(format!("History ({})", self.resets.len()), Event::Tab(Tab::History)), action(format!("Reference pairs ({})", self.references.len()), Event::Tab(Tab::References)), action(format!("Additional changes ({})", self.plan.as_ref().map_or(0, |p| p.impacts.len())), Event::Tab(Tab::Impacts)), Space::new().width(Fill), action("Refresh preview", Event::Preview)].spacing(6).wrap(),
].spacing(8);
let body = match self.tab {
Tab::Facts => self.facts_view(project),
Tab::References => self.references_view(project),
Tab::Members => self.members_view(project),
Tab::History => self.history_view(project),
Tab::Impacts => self.impacts_view(project),
};
content = content.push(body);
@@ -266,7 +290,7 @@ impl State {
.iter()
.filter(|f| f.drift == Drift::Unreviewed)
.count();
content = content.push(text(format!("{} selected decisions · {unreviewed} currently unreviewed facts · Independent keyed/ordered decisions and historical reset remain pending.", self.decisions.len())).size(10).color(style::MUTED));
content = content.push(text(format!("{} decisions · {} exact resets · {unreviewed} unreviewed facts · Ordered, annotation, binding and attached-record decisions remain pending.", self.decisions.len(), self.resets.len())).size(10).color(style::MUTED));
if let Some(trace) = trace {
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
}
@@ -426,6 +450,38 @@ impl State {
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
]
.spacing(10);
if previous.is_some() && history::resettable(&fact.selector) {
let facet = fact.selector.clone();
detail = detail.push(
checkbox(self.resets.contains(&facet))
.label("Reset this exact previous observation before review")
.text_size(11)
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
);
}
if keyed::supported(&fact.selector) {
detail = detail.push(text("Presence changes only this collection entry; referenced objects stay intact. Take preserves a row with no source identity. An explicit source identity that is absent can remove its mapped destination entry.").size(11))
.push(action("Choose / change exact row identities…", Event::EditPair(fact.selector.clone())));
if let Some(baseline) = trace
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
{
for prior in &baseline.facets {
let prior = prior.selector();
if prior != fact.selector && keyed::overlap(&prior, &fact.selector) {
detail = detail.push(
checkbox(self.resets.contains(&prior))
.label(format!(
"Reset overlapping history: {}",
title(project, &prior)
))
.text_size(11)
.on_toggle(move |v| event(Event::Reset(prior.clone(), v))),
);
}
}
}
}
if let Some(proposed) = &self.proposed
&& let Some(trace) = trace
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
@@ -501,6 +557,19 @@ impl State {
"Leave unselected",
Event::Decide(fact.selector.clone(), None),
));
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
&& keyed::supported(&fact.selector)
{
let facet = fact.selector.clone();
detail = detail.push(
checkbox(self.presence.get(&facet).copied().unwrap_or(false))
.label(
"Include in destination collection (unchecked removes only this entry)",
)
.text_size(12)
.on_toggle(move |v| event(Event::Presence(facet.clone(), v))),
);
}
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
&& let Some(draft) = self.candidate_draft(&fact.selector)
&& let Some(paths) = self
@@ -534,17 +603,24 @@ impl State {
} else {
detail = detail.push(text(if scalar::grouped(&fact.selector) { "This constructor is unavailable on one of the current counterparts. Review the outer constructor before its nested kind." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
}
if self.staged.contains(&fact.selector) {
detail = detail.push(action(
"Remove staged address and its decision",
Event::DropStaged(fact.selector.clone()),
));
}
let overlaps: Vec<_> = self
.decisions
.keys()
.filter(|other| {
**other != fact.selector
&& (constructors::contains(&fact.selector, other)
|| constructors::contains(other, &fact.selector))
|| constructors::contains(other, &fact.selector)
|| keyed::overlap(other, &fact.selector))
})
.collect();
if self.decisions.contains_key(&fact.selector) && !overlaps.is_empty() {
detail = detail.push(text("Related decisions are selected. A first equal-kind acknowledgment can coexist with descendants; a replacing group requires one combined decision. Resolve overlaps explicitly; all drafts are retained.").size(11));
detail = detail.push(text("Related decisions are selected. Paired rows cannot review the same OA or SA identity twice. Replacing groups require one combined decision; first equal-kind acknowledgments may coexist with descendants. Resolve overlaps explicitly; all drafts are retained.").size(11));
for other in overlaps {
detail = detail.push(action(
format!("Leave {} unselected", title(project, other)),
@@ -557,6 +633,7 @@ impl State {
));
}
if (is_group
|| keyed::supported(&fact.selector)
|| constructors::selectors().iter().any(|group| {
self.decisions.contains_key(group) && constructors::contains(group, &fact.selector)
}))
@@ -595,10 +672,106 @@ impl State {
detail.into()
}
fn members_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let families = self.member_families(project);
let available = families.contains(&self.member_family);
let mut content = column![
text("Review an individual member or literal").size(16),
text("Choose an address, then review its presence in Properties. This also stages a new row in an empty collection. No model object is created.").size(12),
pick_list(families, Some(self.member_family), |v| event(Event::MemberFamily(v))).text_size(12).padding(7),
].spacing(9);
if !available {
content = content.push(text("This collection is not available on the current counterparts. Choose an available collection above; existing staged addresses remain removable below.").size(12));
} else if self.member_family == keyed::Family::Literal {
content = content.push(text("Literal identity is exact, including case and spaces. Both sides use the same string; a rename requires separate old/new rows.").size(11))
.push(text_input("Exact literal", &self.literal).on_input(|v| event(Event::Literal(v))).size(12).padding(7))
.push(row![checkbox(self.literal_oa).label("Operational identity").text_size(12).on_toggle(|v| event(Event::LiteralSide(Layer::Oa,v))), checkbox(self.literal_sa).label("System identity").text_size(12).on_toggle(|v| event(Event::LiteralSide(Layer::Sa,v)))].spacing(12));
} else {
content = content.push(text("Identities may exist outside the collection. Choosing both explicitly sets their OA → SA correspondence. No identity means an unpaired side, distinct from an absent member.").size(11))
.push(row![pick_list(self.member_choices(project, Layer::Oa), self.member_oa.clone(), |v| event(Event::MemberOa(Some(v)))).placeholder("Operational identity").text_size(12).padding(7).width(Fill), action("No OA identity", Event::MemberOa(None))].spacing(7))
.push(row![pick_list(self.member_choices(project, Layer::Sa), self.member_sa.clone(), |v| event(Event::MemberSa(Some(v)))).placeholder("System identity").text_size(12).padding(7).width(Fill), action("No SA identity", Event::MemberSa(None))].spacing(7));
}
content = content.push(button(text("Use these identities and review presence").size(12)).padding([7,9]).style(style::nav).on_press_maybe(available.then_some(event(Event::StageMember))))
.push(text("Changing a row address retains its earlier decisions and drafts. Reset conflicting historical observations and leave overlapping decisions unselected explicitly.").size(11).color(style::MUTED));
if !self.staged.is_empty() {
content = content.push(text("Staged addresses in this draft").size(13));
for facet in &self.staged {
content = content.push(
row![
text(title(project, facet)).size(11).width(Fill),
action(
"Remove address / decision",
Event::DropStaged(facet.clone())
)
]
.spacing(7),
);
}
}
scrollable(content).height(305).into()
}
fn history_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut content = column![text("Exact previous observations").size(16), text("Check only the observations to forget. Resets run before this review's decisions; reset alone changes history, not model values. Uncheck to restore the pending observation. Prose history remains separate.").size(12)].spacing(9);
let baseline = self
.trace
.and_then(|id| project.traces.get(&id))
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref());
if baseline.is_none_or(|b| b.facets.is_empty()) {
content =
content.push(text("No previous semantic observations on this trace.").size(12));
}
if let Some(baseline) = baseline {
for fact in &baseline.facets {
let facet = fact.selector();
let mut item = column![
text(title(project, &facet)).size(13),
text(format!(
"Operational: {}\nSystem: {}",
observed(project, Some(fact), Layer::Oa),
observed(project, Some(fact), Layer::Sa)
))
.size(11)
]
.spacing(4);
if history::resettable(&facet) {
item = item.push(
checkbox(self.resets.contains(&facet))
.label("Reset this exact observation")
.text_size(11)
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
);
} else {
item = item.push(
text("Artifact history reset remains pending with artifact review.")
.size(11),
);
}
content = content.push(container(item).padding(7));
}
}
for pending in &self.resets {
if baseline.is_none_or(|b| !b.facets.iter().any(|f| f.selector() == *pending)) {
let facet = pending.clone();
content = content.push(
checkbox(true)
.label(format!(
"Pending reset no longer in current history: {}",
title(project, &facet)
))
.text_size(11)
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
);
}
}
scrollable(content).height(305).into()
}
fn references_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
let mut content = column![
text("Reference correspondence · always Operational → System").size(15),
text("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires reset in the structured workflow.").size(12),
text("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires an exact reset in History. Existing decisions are retained; resolve their overlapping row identities explicitly.").size(12),
text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(Event::ReferenceSearch(v))).padding(7).size(12),
row![
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
+64
View File
@@ -225,3 +225,67 @@ scope protection. Both delegated core defects are resolved and exercised.
Owned-file rustfmt check, strict Clippy (`--all-targets -- -D warnings`) and the
desktop build all pass using the dedicated target and commands above. Native checks remain root-owned. No native application
was launched, and full milestone 3 / GUI parity / release acceptance remain open.
## Independent unordered membership and exact reset slice
Current scope: seven unordered reference collections (function input/output items,
signal/exchange items, transition triggers/effects, constrained identities) and exact
enumeration literals. Stage an explicit OA/SA address, then choose Keep, Take or
Candidate presence. New rows never create referenced model objects or infer pairs.
Pair changes preserve existing decisions and require explicit history resets and
overlap resolution. Literal pairing uses the same exact string on both sides.
History lists exact previous observations, with reversible pending resets applied
before catalog construction, constructor scope and candidate composition. A reset-only
Apply uses the same core prepared command batch and atomic source/history undo.
Artifact history stays deferred; other read-only value families can have their
exact historical observations reset without exposing value editors.
Independent annotation, binding, ordered/owned/incident/artifact decisions and
multi-source review remain later slices. Existing constructor forms remain intact.
The member-family picker offers collections available on the current counterparts.
Identity pickers reuse the ordinary property editor's typed choices. Presence and
all scalar/group drafts remain separate; leaving a decision or removing a staged
address retains its raw presence draft. A missing source key means a target-local
row that Take preserves; an explicit absent source key can remove only the mapped
entry. Existing model objects and unrelated entries remain intact. Previous member
identities, deleted UUIDs and quoted exact literals remain distinguishable.
History and Members use the existing compact scroll area, and the contextual tabs
wrap. Users can explicitly remove staged addresses and their decisions or uncheck
obsolete pending resets after refresh, without discarding other work. Candidate
projection changes only the selected boolean member entries; the core independently
checks the complete typed candidate and supplies all acknowledged/retired/impact
scope. The GUI never synthesizes accepted history or additional-change commands.
On base `c149f2f`, all **311 desktop tests pass**, including 12 new workflow tests.
The suite covers 30 five-family/direction/Keep-Take-Candidate cases, both typed
function-port item families with reverse Legacy applicability, exact literal
add/remove and default retention, historical pair migration with explicit overlap
resolution, constructor reset before equal-kind acknowledgment, deleted source
members and target-local preservation, exact reset-only source/Undo/Redo, source
reopen/comments, retained drafts, stale/source-write failures and recovery.
Owned-file rustfmt check, strict Clippy (`--all-targets -- -D warnings`) and
`cargo build --locked -p oa-desktop` all pass with the dedicated desktop target.
No core, API, schema or shared header changes were needed.
Root-owned native acceptance uses the fixtures mirrored by `keyed_tests.rs`:
- Signal pair: source contains a mapped item; destination contains a separate local
item. In Members, choose Signal items and both mapped identities, then Take in
Properties. Inspect the proposed present row and retained local row; apply, undo,
redo and inspect source. Repeat in reverse. Test Candidate exclusion as well.
- Enum pair: both contain `retained` with that default; source also contains
` New value ` and destination contains `old`. Stage exact paired literals; Take
the incoming row, Candidate-remove `old`, Candidate-add `new value`. Inspect the
distinct exact strings, unchanged default and one source/history transaction.
- Historical pair: an existing one-sided member observation is replaced by an
explicitly paired address. Apply stays unavailable until that exact old history
is reset and any overlapping earlier decision is explicitly left unselected.
Use History for a reset-only Apply; values and prose history stay unchanged, and
one Undo restores the exact prior source and semantic history.
- At 1100x700, verify wrapped tabs, scrollable member/history content, long/deleted
identity labels, retained errors/decisions, staged-address removal, source failure
repair and apply/cancel access; repeat a normal transaction at 1440x900.
No native application was launched here. Independent review, actual native
acceptance, later keyed/ordered slices and full GUI parity remain open.