Review closed semantic constructor groups with retained typed drafts

This commit is contained in:
2026-09-06 07:49:01 +03:00
parent 0e47384532
commit 1a68ae0795
10 changed files with 2298 additions and 193 deletions
+155 -16
View File
@@ -46,7 +46,8 @@ pub struct Draft {
pub inputs: BTreeMap<String, Input>,
pub exact_strings: bool,
context: Option<Value>,
message_timer_draft: Option<Input>,
pub semantic_groups: bool,
constructor_drafts: BTreeMap<(String, String), (Value, BTreeMap<String, Input>)>,
port_contract_drafts: BTreeMap<String, Value>,
pub value_browsers: BTreeMap<String, ValueBrowser>,
}
@@ -86,7 +87,8 @@ impl Draft {
inputs: BTreeMap::new(),
exact_strings: false,
context: None,
message_timer_draft: None,
semantic_groups: false,
constructor_drafts: BTreeMap::new(),
port_contract_drafts: BTreeMap::new(),
value_browsers: BTreeMap::new(),
};
@@ -250,27 +252,73 @@ impl Draft {
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
Ok(())
}
/// Message-kind selection preserves the timer's exact input/editor state
/// while its payload is temporarily hidden by another kind.
pub fn message_kind_variant(&mut self, path: &str, value: Value) -> Result<(), String> {
serde_json::from_value::<MessageKind>(value.clone()).map_err(|e| e.to_string())?;
/// Preserve each selected constructor branch, including incomplete raw input
/// and editor state. No sibling fields are parsed or cleared by a switch.
pub fn variant_scoped(&mut self, path: &str, proposed: Value) -> Result<(), String> {
let current = self
.value
.pointer(path)
.ok_or("Message kind no longer exists.")?;
if same_variant(current, &value) {
.ok_or("Property no longer exists.")?
.clone();
if same_variant(&current, &proposed) {
return Ok(());
}
let timer_path = format!("{path}/Timer/expression");
if let Some(input) = self.inputs.remove(&timer_path) {
self.message_timer_draft = Some(input);
let constructor = path.ends_with("/DataType/definition")
|| path.ends_with("/Port/contract")
|| path.ends_with("/Occurrence/body")
|| path.ends_with("/Event/body")
|| path.ends_with("/message_kind");
if !constructor {
return self.shape_scoped(path, proposed);
}
self.shape_scoped(path, value)?;
if self.inputs.contains_key(&timer_path)
&& let Some(input) = self.message_timer_draft.take()
// Input/output direction changes carry the item declaration. Read those
// raw reference strings before stashing their editor buffers so an
// incomplete reference cannot silently fall back to its stored UUID.
let port_current = if path.ends_with("/Port/contract") {
self.materialize_scopes(&[path.to_owned()])?
.pointer(path)
.cloned()
} else {
None
};
let tag = |value: &Value| {
value
.as_str()
.or_else(|| value.as_object()?.keys().next().map(String::as_str))
.unwrap_or("")
.to_owned()
};
let previous_key = (path.to_owned(), tag(&current));
let next_key = (path.to_owned(), tag(&proposed));
let mut inputs = BTreeMap::new();
for key in self
.inputs
.keys()
.filter(|key| within(key, path))
.cloned()
.collect::<Vec<_>>()
{
self.inputs.insert(timer_path, input);
inputs.insert(key.clone(), self.inputs.remove(&key).unwrap());
}
self.constructor_drafts
.insert(previous_key, (current.clone(), inputs));
let saved = self.constructor_drafts.remove(&next_key);
let (value, restored) = if let Some(saved) = saved {
saved
} else {
let value = if path.ends_with("/Port/contract") {
port_fields::switch_contract(
port_current.as_ref().unwrap_or(&current),
proposed,
&mut BTreeMap::new(),
)
} else {
proposed
};
(value, BTreeMap::new())
};
self.shape_scoped(path, value)?;
self.inputs.extend(restored);
Ok(())
}
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<String>) {
@@ -1315,6 +1363,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
"layer" => &["Oa", "Sa"],
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
"storage" => &["Value", "Reference"],
"direction" if path.contains("/Port/legacy/") => &["In", "Out", "InOut"],
"direction" if path.contains("DataField") => {
&["Unspecified", "In", "Out", "InOut", "Return"]
}
@@ -1571,7 +1620,7 @@ fn property<'a>(
return view;
}
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
if path.ends_with("/Port/legacy") {
if path.ends_with("/Port/legacy") && !draft.semantic_groups {
let mut content = column![].spacing(4);
if !value.is_null() {
content = content.push(
@@ -1585,6 +1634,29 @@ fn property<'a>(
}
return content.into();
}
if path.ends_with("/Port/legacy") && draft.semantic_groups && !skip_variant {
let mut content = column![
label_view(),
text(
"Retained generic association is part of this group. Change or clear it explicitly."
)
.size(11)
]
.spacing(5);
if value.is_null() {
content = content.push(button(text("Add generic association").size(11)).on_press(
Message::FieldShape(path.into(), json!({"direction":"InOut","interface":null})),
));
} else {
content = content
.push(property(draft, project, value, path, label, true))
.push(
button(text("Clear generic association").size(11))
.on_press(Message::FieldShape(path.into(), Value::Null)),
);
}
return content.into();
}
let enum_choices = if skip_variant {
vec![]
} else {
@@ -1842,6 +1914,73 @@ fn property<'a>(
mod tests {
use super::*;
#[test]
fn semantic_port_group_choices_use_combined_owner_and_retain_invalid_transferred_items() {
let mut p = Project::empty();
let function = p
.add_element(Kind::Function, Layer::Sa, "Function", None)
.unwrap();
let component = p
.add_element(Kind::System, Layer::Sa, "Component", None)
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Item", None)
.unwrap();
let id = Id::new_v4();
p.elements.insert(
id,
ports::make_port(
&p,
id,
function,
"Port",
PortContract::FunctionInput {
incoming_items: vec![item],
},
)
.unwrap(),
);
let mut draft = Draft::from_project(&p, id).unwrap();
draft.semantic_groups = true;
let path = "/body/Sa/Shared/Port/contract";
let input = format!("{path}/FunctionInput/incoming_items/0");
draft.input(&input, "incomplete-id".into());
let cursor = draft.inputs[&input].editor.cursor();
draft
.variant_scoped(path, json!({"FunctionOutput":{"outgoing_items":[]}}))
.unwrap();
assert_eq!(
draft.inputs[&format!("{path}/FunctionOutput/outgoing_items/0")].raw,
"incomplete-id"
);
assert!(serde_json::from_value::<Element>(draft.materialize().unwrap()).is_err());
draft
.variant_scoped(path, json!({"FunctionInput":{"incoming_items":[]}}))
.unwrap();
assert_eq!(draft.inputs[&input].raw, "incomplete-id");
assert_eq!(draft.inputs[&input].editor.cursor(), cursor);
let choices =
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
assert!(
choices
.iter()
.any(|(_, v)| v.get("FunctionInput").is_some())
);
assert!(choices.iter().any(|(_, v)| *v == json!("Legacy")));
assert!(!choices.iter().any(|(_, v)| v.get("Component").is_some()));
let mut context = draft.value.clone();
context["parent"] = json!(component);
draft.set_context(Some(context));
let choices =
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
assert!(choices.iter().any(|(_, v)| v.get("Component").is_some()));
assert!(
!choices
.iter()
.any(|(_, v)| v.get("FunctionInput").is_some())
);
}
fn ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
reference_ids(draft, project, path)
}
+8 -1
View File
@@ -45,7 +45,11 @@ pub(super) fn variants(
let current = draft.context_value();
let sa = current.pointer("/body/Sa").is_some();
if !sa {
return Some(vec![]);
return Some(if draft.semantic_groups {
vec![("Legacy generic port".into(), json!("Legacy"))]
} else {
vec![]
});
}
let owner = current
.get("parent")
@@ -69,6 +73,9 @@ pub(super) fn variants(
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
));
}
if draft.semantic_groups {
choices.push(("Legacy generic port".into(), json!("Legacy")));
}
return Some(choices);
}
if path.ends_with("/Port/contract/Component/kind") {
+90 -75
View File
@@ -8,11 +8,14 @@ use oa_core::{transitions::*, *};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
mod constructors;
mod message_kind;
mod scalar;
mod view;
pub(crate) use view::summary;
#[cfg(test)]
mod constructor_tests;
#[cfg(test)]
mod message_kind_tests;
#[cfg(test)]
mod tests;
@@ -57,9 +60,9 @@ pub struct State {
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
pub references: BTreeMap<Id, Id>,
pub draft: Option<Draft>,
// Group editing cannot reshape the independently authored TimerExpression
// scalar draft. Both drafts survive decision changes until this trace closes.
pub message_draft: Option<Draft>,
// Each closed constructor has an independent draft, including nested kinds.
// Scalar and sibling drafts survive decision and constructor changes.
pub group_drafts: BTreeMap<FacetSelector, Draft>,
pub plan: Option<SemanticTransitionPlan>,
pub proposed: Option<Project>,
pub error: Option<String>,
@@ -102,7 +105,7 @@ impl State {
decisions: BTreeMap::new(),
references: BTreeMap::new(),
draft: None,
message_draft: None,
group_drafts: BTreeMap::new(),
plan: None,
proposed: None,
error: None,
@@ -135,12 +138,19 @@ impl State {
self.draft = self
.target(project)
.and_then(|id| Draft::from_project(project, id));
self.message_draft = self
.trace
.and_then(|id| project.traces.get(&id))
.filter(|trace| message_kind::available(project, trace))
.and_then(|_| self.target(project))
.and_then(|id| Draft::from_project(project, id));
self.group_drafts.clear();
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
for facet in constructors::selectors() {
if constructors::available(project, trace, &facet)
&& let Some(mut draft) = self
.target(project)
.and_then(|id| Draft::from_project(project, id))
{
draft.semantic_groups = true;
self.group_drafts.insert(facet, draft);
}
}
}
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
self.references.insert(trace.oa, trace.sa);
}
@@ -200,19 +210,9 @@ impl State {
Event::Shape(path, value) => {
self.edit(project, &path, |d| d.shape_scoped(&path, value))
}
Event::Variant(path, value) => self.edit(project, &path, |d| {
if path.ends_with("/message_kind") {
d.message_kind_variant(&path, value)
} else if d
.value
.pointer(&path)
.is_some_and(|v| crate::forms::same_variant(v, &value))
{
Ok(())
} else {
d.shape_scoped(&path, value)
}
}),
Event::Variant(path, value) => {
self.edit(project, &path, |d| d.variant_scoped(&path, value))
}
Event::Array(path, index, direction) => {
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
}
@@ -297,21 +297,23 @@ impl State {
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
return Err("Choose Set final value before editing this property.".into());
}
let root = self
let roots = self
.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.ok_or("Selected scalar property is unavailable.")?;
if path != root && !path.starts_with(&format!("{root}/")) {
.and_then(|id| constructors::roots(project, id, facet))
.ok_or("Selected property is unavailable.")?;
if !roots
.iter()
.any(|root| path == root || path.starts_with(&format!("{root}/")))
{
return Err("Only the selected review property can change in this draft.".into());
}
let draft = if message_kind::handles(facet) {
&mut self.message_draft
let draft = if scalar::grouped(facet) {
self.group_drafts.get_mut(facet)
} else {
&mut self.draft
self.draft.as_mut()
}
.as_mut()
.ok_or("Choose an existing target.")?;
// The scoped view sends only descendants of the selected scalar property.
// The scoped view sends only the selected property or closed group roots.
// Protected identity and top-level body/perspective controls are never exposed.
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
return Err("Semantic candidates retain identity, alias and perspective.".into());
@@ -324,17 +326,16 @@ impl State {
.and_then(|id| scalar::binding(project, id, facet))
.is_some()
&& (!scalar::grouped(facet)
|| (message_kind::handles(facet)
&& self.message_draft.is_some()
|| (self.group_drafts.contains_key(facet)
&& self
.trace
.and_then(|id| project.traces.get(&id))
.is_some_and(|trace| message_kind::available(project, trace))))
.is_some_and(|trace| constructors::available(project, trace, facet))))
}
fn candidate_draft(&self, facet: &FacetSelector) -> Option<&Draft> {
if message_kind::handles(facet) {
self.message_draft.as_ref()
if scalar::grouped(facet) {
self.group_drafts.get(facet)
} else {
self.draft.as_ref()
}
@@ -418,7 +419,7 @@ impl State {
Ok(request)
}
/// Compose selected scalar values for both the candidate and dependent form
/// Compose selected values for both the candidate and dependent form
/// 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> {
@@ -446,61 +447,75 @@ impl State {
}
}
map.insert(source, target);
let mut message_candidate = None;
for (facet, decision) in &self.decisions {
let original = final_value.clone();
let mut group_candidates = vec![];
// Outer groups precede nested groups and leaves. The core rejects actual
// overlapping replacement scopes; this order also supports first-equal
// discriminator acknowledgment together with explicit descendant values.
let mut decisions: Vec<_> = self.decisions.iter().collect();
decisions.sort_by_key(|(facet, _)| match facet {
FacetSelector::Occurrence(OccurrenceFacet::Constructor) => 0,
facet if scalar::grouped(facet) => 1,
_ => 2,
});
for (facet, decision) in decisions {
if *decision == SemanticDecision::KeepTarget {
continue;
}
let path = scalar::binding(project, target, facet)
let roots = constructors::roots(project, target, facet)
.ok_or("Selected property is not applicable to the target.")?;
let value = match decision {
let supplied = match decision {
SemanticDecision::KeepTarget => continue,
SemanticDecision::Candidate => {
let draft = self
.candidate_draft(facet)
.ok_or("Target draft is unavailable.")?;
let value = match draft.materialize_scopes(std::slice::from_ref(&path)) {
match draft.materialize_scopes(&roots) {
Ok(value) => value,
Err(_) if form_context => draft.value.clone(),
Err(error) => return Err(error),
};
value
.pointer(&path)
.cloned()
.ok_or("Candidate property is no longer available.")?
}
}
SemanticDecision::TakeSource if message_kind::handles(facet) => {
// First equal kinds acknowledge only the discriminator.
// An unselected Timer payload must stay at the destination.
message_kind::source_value(
project,
if self.can_acknowledge_constructor(project, facet) {
target
} else {
source
},
)?
SemanticDecision::TakeSource if scalar::grouped(facet) => {
if self.can_acknowledge_constructor(project, facet) {
original.clone()
} else {
constructors::take_value(project, trace, self.direction, facet, &map)?
}
}
SemanticDecision::TakeSource => {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::source_value(&fact, self.direction.source_layer(), &map)?
let value = scalar::source_value(&fact, self.direction.source_layer(), &map)?;
let mut supplied = original.clone();
*supplied
.pointer_mut(&roots[0])
.ok_or("Source property is unavailable.")? = value;
supplied
}
};
if message_kind::handles(facet) && *decision == SemanticDecision::Candidate {
message_candidate = Some((path.clone(), value.clone()));
for path in roots {
let value = supplied
.pointer(&path)
.cloned()
.ok_or("Candidate property is no longer available.")?;
if scalar::grouped(facet) && *decision == SemanticDecision::Candidate {
group_candidates.push((path.clone(), value.clone()));
}
*final_value.pointer_mut(&path).ok_or("Final target property is unavailable; resolve the overlapping constructor decision.")? = value;
}
*final_value
.pointer_mut(&path)
.ok_or("Final target property is unavailable.")? = value;
}
if !form_context
&& let Some((path, candidate)) = message_candidate
&& let Ok(current) = message_kind::source_value(project, target)
&& crate::forms::same_variant(&candidate, &current)
&& candidate != current
&& final_value.pointer(&path) != Some(&candidate)
{
return Err("The message-kind candidate and Timer Expression decision propose different timer values. Keep the kind or make their final values agree; both drafts are retained.".into());
if !form_context {
for (path, authored) in group_candidates {
if !original
.pointer(&path)
.zip(final_value.pointer(&path))
.is_some_and(|(original, final_value)| {
constructors::authored_agrees(original, &authored, final_value)
})
{
return Err("The constructor candidate and descendant decision propose different final values. Keep the kind or make their final values agree; both drafts are retained.".into());
}
}
}
Ok(final_value)
}
@@ -513,8 +528,8 @@ impl State {
if let Some(draft) = &mut self.draft {
draft.set_context(context.clone());
}
if let Some(draft) = &mut self.message_draft {
draft.set_context(context);
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 {
self.error = Some(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,327 @@
//! Closed constructor payload bindings. The core owns decisions and review scope;
//! this adapter supplies complete target records for its Candidate agreement check.
use super::{scalar, *};
pub(super) fn selectors() -> [FacetSelector; 5] {
[
FacetSelector::Data(DataFacet::Constructor),
FacetSelector::Port(PortFacet::Constructor),
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
FacetSelector::Event(EventFacet::Constructor),
]
}
pub(super) fn roots(project: &Project, target: Id, facet: &FacetSelector) -> Option<Vec<String>> {
let root = scalar::binding(project, target, facet)?;
let mut roots = vec![root.clone()];
match facet {
FacetSelector::Data(DataFacet::Constructor) => {
roots.push(format!("{}/default", root.strip_suffix("/definition")?));
}
FacetSelector::Port(PortFacet::Constructor) => {
roots.push(format!("{}/legacy", root.strip_suffix("/contract")?));
}
_ => {}
}
Some(roots)
}
pub(super) fn available(project: &Project, trace: &Trace, facet: &FacetSelector) -> bool {
roots(project, trace.oa, facet).is_some() && roots(project, trace.sa, facet).is_some()
}
/// Used for explanation and explicit overlap controls, never for acknowledgment.
pub(super) fn contains(group: &FacetSelector, facet: &FacetSelector) -> bool {
use FacetSelector as F;
match group {
F::Data(DataFacet::Constructor) => {
matches!(facet, F::Data(f) if *f != DataFacet::Unit) || matches!(facet, F::Literal(_))
}
F::Port(PortFacet::Constructor) => matches!(
facet,
F::Port(_)
| F::Member {
collection: ReferenceCollection::FunctionInputItems
| ReferenceCollection::FunctionOutputItems,
..
}
),
F::Occurrence(OccurrenceFacet::Constructor) => {
matches!(facet, F::Occurrence(f) if !matches!(f, OccurrenceFacet::Precondition | OccurrenceFacet::Postcondition))
|| matches!(
facet,
F::Annotation(_)
| F::Binding(_)
| F::Member {
collection: ReferenceCollection::Steps
| ReferenceCollection::FragmentBranches,
..
}
| F::Order(_)
)
}
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
facet,
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
),
F::Event(EventFacet::Constructor) => matches!(
facet,
F::Event(_)
| F::Member {
collection: ReferenceCollection::SignalItems,
..
}
),
_ => false,
}
}
pub(super) fn description(facet: &FacetSelector) -> &'static str {
match facet {
FacetSelector::Data(_) => {
"Definition, bounds or literal members, record supertype and default. Unit stays independent."
}
FacetSelector::Port(_) => {
"Contract, item members and retained generic association. Owner stays independent."
}
FacetSelector::Occurrence(OccurrenceFacet::Constructor) => {
"Occurrence body, including references, message annotations, bindings and branch or step order. Pre/postconditions stay independent."
}
FacetSelector::Occurrence(_) => {
"Message kind and Timer Expression. Exchange references and annotations stay independent."
}
FacetSelector::Event(_) => {
"Event body, including signal items or its complete time/change expression."
}
_ => "",
}
}
fn chosen<T>(keys: &KeyPair<T>, layer: Layer) -> Option<&T> {
match layer {
Layer::Oa => keys.oa.as_ref(),
Layer::Sa => keys.sa.as_ref(),
}
}
// The source payload is mapped by the core's typed visitor, never by replacing
// UUID-looking strings. For a historical same-kind Take, retain only current
// target-present/source-absent rows with no prior source-present observation. The final core planner
// checks this assembled record against its own independent expected result.
pub(super) fn take_value(
project: &Project,
trace: &Trace,
direction: TransitionDirection,
facet: &FacetSelector,
map: &BTreeMap<Id, Id>,
) -> Result<Value, String> {
let (source, target) = if direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
} else {
(trace.sa, trace.oa)
};
let mut element = project
.elements
.get(&source)
.cloned()
.ok_or("Missing constructor source.")?;
if !message_kind::handles(facet) {
element.parent = None;
let mut missing = BTreeSet::new();
oa_core::references::visit_element_refs(&mut element, |id| {
if let Some(mapped) = map.get(id) {
*id = *mapped;
} else {
missing.insert(*id);
}
});
if !missing.is_empty() {
return Err(format!(
"Missing explicit reference correspondence: {missing:?}"
));
}
}
let supplied = serde_json::to_value(element).map_err(|e| e.to_string())?;
let mut value = Draft::from_project(project, target)
.ok_or("Missing target draft.")?
.value;
for (from, to) in roots(project, source, facet)
.ok_or("Source group is unavailable.")?
.iter()
.zip(roots(project, target, facet).ok_or("Target group is unavailable.")?)
{
*value
.pointer_mut(&to)
.ok_or("Target group payload is unavailable.")? = supplied
.pointer(from)
.cloned()
.ok_or("Source group payload is unavailable.")?;
}
let current = capture_facet(project, trace.oa, trace.sa, facet)?;
if scalar::observation(&current, Layer::Oa) != scalar::observation(&current, Layer::Sa) {
return Ok(value);
}
let references = if direction == TransitionDirection::OaToSa {
map.clone()
} else {
map.iter().map(|(sa, oa)| (*oa, *sa)).collect()
};
let old = trace.baseline.as_ref().and_then(|b| b.semantic.as_ref());
for fact in semantic_facets(project, trace, &references)? {
if !contains(facet, &fact.selector) {
continue;
}
let Some((path, key)) =
target_row(project, target, &fact.selector, direction.target_layer())
else {
continue;
};
if row_present(&fact.current, direction.source_layer())
|| !row_present(&fact.current, direction.target_layer())
{
continue;
}
let was_paired = old.is_some_and(|b| {
b.facets.iter().any(|previous| {
row_present(previous, direction.source_layer())
&& target_row(
project,
target,
&previous.selector(),
direction.target_layer(),
)
.as_ref()
== Some(&(path.clone(), key.clone()))
})
});
if was_paired {
continue;
}
let row = scalar::value(&fact.current, direction.target_layer())
.ok_or("Target row is inapplicable.")?;
let items = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Target collection is unavailable.")?;
match &fact.selector {
FacetSelector::Member { .. } | FacetSelector::Literal(_) => {
if row == Value::Bool(true) && !items.contains(&key) {
items.push(key);
} else if row == Value::Bool(false) {
items.retain(|v| v != &key);
}
}
FacetSelector::Annotation(_) => {
items.retain(|v| {
serde_json::from_value::<MessageValue>(v.clone())
.ok()
.map(|v| serde_json::to_value(AnnotationKey::from(&v)).unwrap())
.as_ref()
!= Some(&key)
});
if !row.is_null() {
items.push(row);
}
}
FacetSelector::Binding(_) => {
items.retain(|v| v.get("referenced") != Some(&key));
if !row.is_null() {
items.push(row);
}
}
_ => unreachable!(),
}
}
Ok(value)
}
fn row_present(fact: &FacetReview, layer: Layer) -> bool {
match fact {
FacetReview::Member { .. } | FacetReview::Literal { .. } => {
scalar::value(fact, layer) == Some(Value::Bool(true))
}
FacetReview::Annotation { .. } | FacetReview::Binding { .. } => {
scalar::value(fact, layer).is_some_and(|value| !value.is_null())
}
_ => false,
}
}
fn row_key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
match facet {
FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => {
chosen(keys, layer).map(|v| serde_json::json!(v))
}
FacetSelector::Literal(keys) => chosen(keys, layer).map(|v| serde_json::json!(v)),
FacetSelector::Annotation(keys) => chosen(keys, layer).map(|v| serde_json::json!(v)),
_ => None,
}
}
fn target_row(
project: &Project,
target: Id,
facet: &FacetSelector,
layer: Layer,
) -> Option<(String, Value)> {
let key = row_key(facet, layer)?;
let (group, suffix) = match facet {
FacetSelector::Literal(_) => (
FacetSelector::Data(DataFacet::Constructor),
"/Enumeration/literals",
),
FacetSelector::Binding(_) => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
"/ScenarioUse/bindings",
),
FacetSelector::Annotation(_) => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
"/Exchange/values",
),
FacetSelector::Member { collection, .. } => match collection {
ReferenceCollection::FunctionInputItems => (
FacetSelector::Port(PortFacet::Constructor),
"/FunctionInput/incoming_items",
),
ReferenceCollection::FunctionOutputItems => (
FacetSelector::Port(PortFacet::Constructor),
"/FunctionOutput/outgoing_items",
),
ReferenceCollection::SignalItems => (
FacetSelector::Event(EventFacet::Constructor),
"/Signal/items",
),
ReferenceCollection::Steps => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
"/Branch/occurrences",
),
ReferenceCollection::FragmentBranches => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
"/Fragment/branches",
),
_ => return None,
},
_ => return None,
};
Some((
format!("{}{suffix}", scalar::binding(project, target, &group)?),
key,
))
}
/// A selected descendant may fill untouched group fields during first-equal
/// acknowledgment, but cannot silently replace an authored group field.
pub(super) fn authored_agrees(original: &Value, authored: &Value, final_value: &Value) -> bool {
if original == authored || authored == final_value {
return true;
}
match (original, authored, final_value) {
(Value::Object(a), Value::Object(b), Value::Object(c)) if a.keys().eq(b.keys()) => {
b.iter().all(|(key, v)| {
c.get(key)
.is_some_and(|final_value| authored_agrees(&a[key], v, final_value))
})
}
_ => false,
}
}
@@ -5,12 +5,14 @@ use super::*;
pub(super) fn selector() -> FacetSelector {
FacetSelector::Occurrence(OccurrenceFacet::MessageKind)
}
#[cfg(test)]
pub(super) fn timer_selector() -> FacetSelector {
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression)
}
pub(super) fn handles(facet: &FacetSelector) -> bool {
*facet == selector()
}
#[cfg(test)]
pub(super) fn value(project: &Project, id: Id) -> Option<&MessageKind> {
match project.elements.get(&id)?.shared()? {
SharedBody::Occurrence {
@@ -20,13 +22,3 @@ pub(super) fn value(project: &Project, id: Id) -> Option<&MessageKind> {
_ => None,
}
}
pub(super) fn available(project: &Project, trace: &Trace) -> bool {
value(project, trace.oa).is_some() && value(project, trace.sa).is_some()
}
/// MessageConstructor observations contain only the discriminator. Taking a
/// replacing group needs the complete typed payload, including Timer.expression.
pub(super) fn source_value(project: &Project, id: Id) -> Result<Value, String> {
let value =
value(project, id).ok_or("Message kind requires an existing Exchange occurrence.")?;
serde_json::to_value(value).map_err(|e| e.to_string())
}
@@ -206,10 +206,18 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex
&mut app,
Event::Text(expression.clone(), text_editor::Action::SelectAll),
);
let cursor = state(&app).message_draft.as_ref().unwrap().inputs[&expression]
let cursor = state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.editor
.cursor();
let selection = state(&app).message_draft.as_ref().unwrap().inputs[&expression]
let selection = state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.editor
.selection();
send(
@@ -221,17 +229,30 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex
Event::Variant(kind, json!({"Timer":{"expression":""}})),
);
assert_eq!(
state(&app).message_draft.as_ref().unwrap().inputs[&expression].raw,
state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.raw,
" group β timer\n\t "
);
assert_eq!(
state(&app).message_draft.as_ref().unwrap().inputs[&expression]
state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.editor
.cursor(),
cursor
);
assert_eq!(
state(&app).message_draft.as_ref().unwrap().inputs[&expression]
state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.editor
.selection(),
selection
@@ -621,7 +642,7 @@ fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_t
.error
.as_ref()
.unwrap()
.contains("different timer values")
.contains("different final values")
);
send(
&mut app,
@@ -682,7 +703,12 @@ fn message_group_rejects_protected_edits_stale_plan_and_source_failure_without_l
assert!(state(&app).error.is_some());
assert_eq!(state(&app).decisions.len(), 1);
assert_eq!(
state(&app).message_draft.as_ref().unwrap().inputs[&expression].raw,
state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.raw,
raw
);
assert_eq!(app.document.bundle(), &before.bundle);
@@ -568,7 +568,10 @@ fn read_only_structured_facts_and_partial_rollup_are_honest() {
send(
&mut app,
Event::Decide(
facet(DataFacet::Constructor),
FacetSelector::Literal(KeyPair {
oa: Some("Unpaired".into()),
sa: None,
}),
Some(SemanticDecision::TakeSource),
),
);
+119 -68
View File
@@ -266,7 +266,7 @@ impl State {
.iter()
.filter(|f| f.drift == Drift::Unreviewed)
.count();
content = content.push(text(format!("{} selected decisions · {unreviewed} currently unreviewed facts · Other constructor groups and structured collections require further review.", self.decisions.len())).size(10).color(style::MUTED));
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));
if let Some(trace) = trace {
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
}
@@ -364,31 +364,42 @@ impl State {
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.and_then(|b| b.facets.iter().find(|f| f.selector() == fact.selector));
let is_message_kind = message_kind::handles(&fact.selector);
let timer_previous = trace
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.and_then(|b| {
b.facets
.iter()
.find(|f| f.selector() == message_kind::timer_selector())
});
let timer_current = self
.facts
.iter()
.find(|f| f.selector == message_kind::timer_selector());
let is_group = scalar::grouped(&fact.selector);
let group_observations = |project: &Project, facts: Vec<&FacetReview>, layer| {
facts
.into_iter()
.filter(|f| constructors::contains(&fact.selector, &f.selector()))
.map(|f| {
format!(
"{}: {}",
title(project, &f.selector()),
observed(project, Some(f), layer)
)
})
.collect::<Vec<_>>()
.join("\n")
};
let side = |layer, label: &str| {
let mut before = observed(project, previous, layer);
let mut current = observed(project, Some(&fact.current), layer);
if is_message_kind {
before.push_str(&format!(
"\nTimer expression: {}",
observed(project, timer_previous, layer)
));
current.push_str(&format!(
"\nTimer expression: {}",
observed(project, timer_current.map(|f| &f.current), layer)
));
if is_group {
before = group_observations(
project,
trace
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.map(|b| b.facets.iter().collect())
.unwrap_or_default(),
layer,
);
if before.is_empty() {
before = "No previous group observations".into();
}
current = group_observations(
project,
self.facts.iter().map(|f| &f.current).collect(),
layer,
);
}
column![
text(format!(
@@ -426,11 +437,17 @@ impl State {
.color(style::TEAL),
)
.push(
text(if is_message_kind {
self.target(project)
.and_then(|id| message_kind::source_value(proposed, id).ok())
.map(|value| formatted(proposed, &value, &BTreeSet::new()))
.unwrap_or_else(|| "Message kind is not applicable.".into())
text(if is_group {
semantic_facets(proposed, &proposed.traces[&trace.id], &self.references)
.ok()
.map(|facts| {
group_observations(
proposed,
facts.iter().map(|f| &f.current).collect(),
self.direction.target_layer(),
)
})
.unwrap_or_else(|| "Group payload is unavailable.".into())
} else {
observed(proposed, Some(&after), self.direction.target_layer())
})
@@ -444,10 +461,11 @@ impl State {
);
}
if self.editable(project, &fact.selector) {
if is_message_kind {
detail = detail.push(text("A replacing message-kind decision reviews its Timer Expression as a group. The exact acknowledgment and retirement appear below.").size(12));
if is_group {
detail = detail.push(text(constructors::description(&fact.selector)).size(12));
detail = detail.push(text("Replacing a kind reviews its complete payload. Review every removed reference, row and prior observation in the exact scope below; changing kind retains your draft for switching back.").size(11));
if self.can_acknowledge_constructor(project, &fact.selector) {
detail = detail.push(text("These initial kinds are equal. Keep or Take acknowledges only the kind; an unchanged final kind also needs a separate Timer Expression decision for expression edits.").size(11).color(style::MUTED));
detail = detail.push(text("These initial kinds are equal. Keep or Take acknowledges only the kind; an unchanged Candidate kind needs independent descendant decisions for payload edits.").size(11).color(style::MUTED));
}
}
let destination = if self.direction.target_layer() == Layer::Sa {
@@ -472,8 +490,8 @@ impl State {
]
.spacing(5);
buttons = buttons.push(action(
if is_message_kind {
"Set final message kind…"
if is_group {
"Set final kind and payload…"
} else {
"Set final value…"
},
@@ -485,52 +503,85 @@ impl State {
));
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
&& let Some(draft) = self.candidate_draft(&fact.selector)
&& let Some(path) = self
&& let Some(paths) = self
.target(project)
.and_then(|id| scalar::binding(project, id, &fact.selector))
.and_then(|id| constructors::roots(project, id, &fact.selector))
{
detail = detail.push(text("Final destination value").size(12)).push(
crate::forms::scoped(draft, project, &path, &title(project, &fact.selector))
detail = detail.push(text("Final destination value").size(12));
for path in paths {
let label = if path.ends_with("/default") {
"Default"
} else if path.ends_with("/legacy") {
"Generic association"
} else {
"Kind and payload"
};
detail = detail.push(
crate::forms::scoped(
draft,
project,
&path,
if is_group { label } else { "Final value" },
)
.map(form_event),
);
);
}
}
} else if self.can_acknowledge_constructor(project, &fact.selector) {
detail = detail.push(text("Both constructor kinds are equal and have no prior observation. Acknowledge only the kind; its properties remain independent.").size(12))
.push(action("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
} else {
detail = detail.push(text(if scalar::grouped(&fact.selector) { "Constructor groups are shown for comparison. Group replacement and its complete descendant review are a later milestone." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
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 is_message_kind || fact.selector == message_kind::timer_selector() {
if self.decisions.contains_key(&message_kind::selector())
&& self.decisions.contains_key(&message_kind::timer_selector())
{
detail = detail.push(text("Both kind and timer decisions are selected. An initial equal-kind acknowledgment can coexist with a timer decision; a replacing group requires one combined decision. Resolve any overlap explicitly; both drafts are retained.").size(11))
.push(action("Replace timer decision with message-kind group", Event::Decide(message_kind::timer_selector(), None)))
.push(action("Leave message kind unselected; use timer decision", Event::Decide(message_kind::selector(), None)));
let overlaps: Vec<_> = self
.decisions
.keys()
.filter(|other| {
**other != fact.selector
&& (constructors::contains(&fact.selector, other)
|| constructors::contains(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));
for other in overlaps {
detail = detail.push(action(
format!("Leave {} unselected", title(project, other)),
Event::Decide(other.clone(), None),
));
}
if let Some(review) = self.plan.as_ref().and_then(|p| p.reviews.first()) {
detail = detail.push(text("Exact review scope from preview").size(13));
for (label, selectors) in [
("Acknowledge", &review.acknowledged),
("Retire previous observation", &review.retired),
] {
detail = detail.push(
text(format!(
"{label}: {}",
if selectors.is_empty() {
"None".into()
} else {
selectors
.iter()
.map(|s| title(project, s))
.collect::<Vec<_>>()
.join(", ")
}
))
.size(11),
);
}
detail = detail.push(action(
"Leave this decision unselected; use the related decisions",
Event::Decide(fact.selector.clone(), None),
));
}
if (is_group
|| constructors::selectors().iter().any(|group| {
self.decisions.contains_key(group) && constructors::contains(group, &fact.selector)
}))
&& let Some(review) = self.plan.as_ref().and_then(|p| p.reviews.first())
{
detail = detail.push(text("Exact review scope from preview").size(13));
for (label, selectors) in [
("Acknowledge", &review.acknowledged),
("Retire previous observation", &review.retired),
] {
detail = detail.push(
text(format!(
"{label}: {}",
if selectors.is_empty() {
"None".into()
} else {
selectors
.iter()
.map(|s| title(project, s))
.collect::<Vec<_>>()
.join(", ")
}
))
.size(11),
);
}
}
if matches!(
+98 -15
View File
@@ -1,8 +1,8 @@
# Semantic desktop review — scalars and nested message kind
# Semantic desktop review — scalars and closed constructor groups
Status: milestones 1–2 implemented; the bounded MessageKind/TimerExpression
constructor slice is implemented. Full milestone 3, GUI parity, native acceptance
and release acceptance remain outstanding.
Status: milestones 1–2 and the closed constructor group forms are implemented.
Independent keyed/ordered review, historical pairing reset, multi-source review,
full GUI parity, native acceptance and release acceptance remain outstanding.
Base: `d130013`. Dedicated build target:
`D:/CodexBuilds/Capella2-semantic-desktop-target`. Native verification is root-owned.
@@ -17,10 +17,10 @@ Base: `d130013`. Dedicated build target:
- Support every applicable scalar leaf through Keep, Take and typed final-value
forms. Candidate records contain the complete combined selected result. Identity,
alias and layer are protected. The closed core remains the validation boundary.
- State.Kind is a single structural scalar. Occurrence.MessageKind now supports
its complete nested group, including TimerExpression. Other constructor switches, keyed
membership/order/annotation/binding/attachment decisions and pairing resets belong
to milestone 3; show them read-only with an honest explanation.
- State.Kind is a single structural scalar. Data, Port, Occurrence, nested
MessageKind and Event support their closed constructor groups and complete
typed payloads. Independent keyed membership/order/annotation/binding/attachment
decisions and pairing resets remain visibly read-only outside a replacing group.
- Core `6bb2583` permits first equal constructor acknowledgment without descendant
expansion. The GUI exposes **Acknowledge equal kind only** when applicable;
previously reviewed constructors remain governed by the core's switch rules.
@@ -38,9 +38,9 @@ Base: `d130013`. Dedicated build target:
2. Scalar decisions and typed candidates; immutable initial observations; combined
final records; exact impact acceptance; atomic document/source commit. Focused
App event/source/history tests, strict Clippy and a dedicated desktop build.
3. Nested MessageKind/TimerExpression closure is implemented. Other constructor
groups, keyed and ordered facts, reset, complete artifact lifecycle, multi-source
review and remaining source/REST parity cases are outstanding.
3. All closed constructor forms are implemented, including nested MessageKind.
Independent keyed and ordered facts, reset, complete artifact lifecycle,
multi-source review and remaining source/REST parity cases are outstanding.
4. Root-owned native review, cross-surface fixtures, package/release acceptance.
## Verification
@@ -94,7 +94,8 @@ button availability, exact source retention before Apply, and one-step undo.
Occurrence.MessageKind is a complete closed nested family: Asynchronous, Call,
Reply, Create, Delete and Timer. It is available only when both traced counterparts
are Exchange occurrences. The only constructor-specific payload is the timer
expression. Outer occurrence replacement remains read-only.
expression. This first slice left outer occurrence replacement read-only; the later closed
constructor slice below now covers it.
- Keep/Take/Candidate use the core's switch and first-equal acknowledgment policy.
Taking a replacing group copies the typed MessageKind, including its expression.
@@ -132,9 +133,8 @@ The build target is idle. No native application was launched by this task.
## Remaining work
Milestone 3 must add the remaining constructor closure/replacement, keyed and ordered
decisions, historical pairing reset, complete artifact lifecycle and multi-source
review. These facts are currently inspectable, with unsupported writes visibly
Milestone 3 must add independent keyed and ordered decisions, historical pairing
reset, complete artifact lifecycle and multi-source review. These facts are currently inspectable, with unsupported writes visibly
identified. The reference table supplies explicit mappings for scalar reference
Take; it does not claim to complete a reset or establish keyed acknowledgments.
@@ -142,3 +142,86 @@ Fresh independent requirements/correctness review and root-owned native checks o
the message-kind slice are required before release acceptance. Verify the compact
kind picker, timer text/focus retention, overlap choices, exact closure/retirement,
mapped Reply, blocked Timer contract, source reopening and toolbar wrapping.
## Remaining closed constructor families (this slice)
Owned files: `semantic_review/**`, `semantic_review.rs`, the scoped Draft adapter
in `forms.rs`, semantic-only port choices in `forms/port_fields.rs`, and this ledger.
No shared traceability header, core, API, native state or source language edits.
Base: `f73551f`; same exclusive desktop target.
| Constructor | Complete group payload | Independent fields |
| --- | --- | --- |
| Data | Definition, bounds, literal members, record supertype, default | Unit, owner and headers |
| Port | Contract, function item members, retained legacy direction/interface | Owner and headers |
| Occurrence | Body, nested message kind/timer, references, qualified annotations, bindings, branch and step order | Pre/postconditions, owner and headers |
| Event | Body, signal item members, time kind/expression or change expression | Owner and headers |
| MessageKind | Complete nested kind and timer expression | Other occurrence body properties |
Each group owns an independent draft, including the nested MessageKind group.
Keep/Take/leave-unselected retain all drafts. Constructor picker changes save the
old branch value plus its raw inputs and editor state. Switching back restores them.
Port input/output changes also carry authored item reference strings, including
invalid strings, rather than falling back to stored UUIDs. A Data definition switch
leaves its existing default visible until the user deliberately changes or clears
it; Port's retained generic association has explicit add/edit/clear controls.
The same combined candidate projection supplies dependent form context. Only the
selected roots are materialized; unrelated invalid scalar/group drafts stay local.
Full Take payloads use the public core typed reference visitor, so UUID-shaped prose
is never remapped. For historical same-kind Take, the closed adapter retains only
current target-present/source-absent rows without a previous source-present
observation on that target slot; the core independently
checks the supplied final record. No GUI acknowledgment, retirement or impact list
is synthesized. The core remains authoritative for those lists and applicability.
Group details show every baseline/current/proposed group observation, including
qualified and historical row identities. Actual preview acknowledged/retired lists
remain explicit. A replacing group and selected descendants retain both decisions
and provide explicit leave-unselected actions. First-equal discriminator observation
can coexist with independent descendant decisions. Conflicting authored values
reject instead of silently discarding either draft's edits.
Array controls support the whole authorized payload, including order, existing
owned branches/steps, signal items, annotations and scenario bindings. They do not
create referenced semantic objects, change their ownership, delete dependencies,
or infer reference correspondences. Incompatible payload/owner combinations stay
visible as validation failures. Apply still sends exactly one prepared Document
batch, preserving comments, source/model/history agreement and one-step undo/redo.
New coverage exercises all six Data definitions and companion defaults in both
directions; all eight Occurrence and all three Event kinds; all four Port forms;
combined owner context; nested and scalar draft retention; explicit overlap;
protected fields; mapped record, state, event item and scenario binding payloads;
complete owned branch/step order; historical retirement and first-equal scope;
90 Data/Event kind-pair and direction comparisons against the core-only preview;
source comments/reopen and atomic undo/redo. Full checks and core dependency status
will be recorded at freeze. This task launches no native application.
The delegated keyed-closure fix `43370de` is integrated into SEM as `9c3184b`.
Reverse Literal/Member/Binding/Annotation closure and historical local-member
preservation now pass the desktop desired-success tests. The separately delegated existing-target Port fix `8a75294` is integrated into
SEM as `7512ac8`. Fresh reverse Keep and Candidate reviews can preserve/edit the
OA Legacy association; taking a typed contract into OA still rejects. Both fixes
are covered by desired-success desktop tests; no GUI workaround weakens validation.
Native acceptance remains root-owned: use the existing trace Review semantics
entry, inspect complete group before/after and retired identities, switch variants
away/back, resolve explicit nested overlaps, repair invalid defaults/references,
apply once, inspect source, undo/redo, save and reopen at 1100×700 and 1440×900.
## Closed constructor freeze verification
On integrated SEM base `7512ac8`, all **299 desktop tests pass**. This adds 13
constructor workflow tests and one scoped port form regression to the previous
285-test slice. The new suite includes 90 complete Data/Event kind-pair/direction
comparisons against core previews, all closed candidate kinds, mapped qualified
annotation addresses, bindings and references, actual reverse Legacy edits, owned
branch/step ordering, source/model/history agreement, retained drafts and strict
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.