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
+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(