Files
ARCADIA-Studio/crates/oa-desktop/src/semantic_review.rs
T

572 lines
20 KiB
Rust

//! Deliberate semantic review of one existing trace; drafts never mutate the model.
use crate::{
app::{Choice, Message},
forms::Draft,
};
use iced::widget::text_editor;
use oa_core::{transitions::*, *};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
mod scalar;
mod view;
pub(crate) use view::summary;
#[cfg(test)]
mod tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Facts,
References,
Impacts,
}
#[derive(Debug, Clone)]
pub enum Event {
Trace(Choice),
Direction(TransitionDirection),
Select(FacetSelector),
Decide(FacetSelector, Option<SemanticDecision>),
Input(String, String),
Text(String, text_editor::Action),
Shape(String, Value),
Variant(String, Value),
Array(String, Option<usize>, i32),
Append(String, Value),
ValuePath(String, crate::data_values::FieldPathKey),
Tab(Tab),
ShowUnchanged(bool),
ReferenceOa(Choice),
ReferenceSa(Choice),
ReferenceSearch(String),
AddReference,
RemoveReference(Id),
AcceptImpact(usize, bool),
Preview,
}
pub struct State {
pub anchor: Id,
pub trace: Option<Id>,
pub direction: TransitionDirection,
pub facts: Vec<FacetDrift>,
pub selected: Option<FacetSelector>,
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
pub references: BTreeMap<Id, Id>,
pub draft: Option<Draft>,
pub plan: Option<SemanticTransitionPlan>,
pub proposed: Option<Project>,
pub error: Option<String>,
pub tab: Tab,
pub show_unchanged: bool,
pub reference_oa: Option<Choice>,
pub reference_sa: Option<Choice>,
pub reference_search: String,
accepted_impacts: Vec<SemanticImpact>,
}
impl State {
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
let explicit = project.traces.get(&selected);
let anchor = explicit.map_or(selected, |trace| trace.oa);
let layer = project
.layer_of(anchor)
.ok_or("Select an OA or SA object, relationship or trace to review.")?;
let choices: Vec<_> = project
.traces
.values()
.filter(|t| t.oa == anchor || t.sa == anchor)
.collect();
if choices.is_empty() {
return Err("This object has no trace. Create a counterpart or link existing counterparts first.".into());
}
let trace = explicit
.map(|t| t.id)
.or_else(|| (choices.len() == 1).then(|| choices[0].id));
let mut state = Self {
anchor,
trace,
direction: if layer == Layer::Oa {
TransitionDirection::OaToSa
} else {
TransitionDirection::SaToOa
},
facts: vec![],
selected: None,
decisions: BTreeMap::new(),
references: BTreeMap::new(),
draft: None,
plan: None,
proposed: None,
error: None,
tab: Tab::Facts,
show_unchanged: false,
reference_oa: None,
reference_sa: None,
reference_search: String::new(),
accepted_impacts: vec![],
};
state.load_trace(project);
Ok(state)
}
pub fn target(&self, project: &Project) -> Option<Id> {
let trace = project.traces.get(&self.trace?)?;
Some(if self.direction == TransitionDirection::OaToSa {
trace.sa
} else {
trace.oa
})
}
fn load_trace(&mut self, project: &Project) {
self.facts.clear();
self.decisions.clear();
self.selected = None;
self.accepted_impacts.clear();
self.references.clear();
self.draft = self
.target(project)
.and_then(|id| Draft::from_project(project, id));
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
self.references.insert(trace.oa, trace.sa);
}
self.refresh(project);
}
pub fn update(&mut self, project: &Project, event: Event) {
let mut replan = true;
let result = match event {
Event::Trace(choice) => {
if !project
.traces
.get(&choice.id)
.is_some_and(|t| t.oa == self.anchor || t.sa == self.anchor)
{
Err("Choose one of this object's existing traces.".into())
} else {
self.trace = Some(choice.id);
self.load_trace(project);
return;
}
}
Event::Direction(direction) => {
self.direction = direction;
self.load_trace(project);
return;
}
Event::Select(facet) => {
self.selected = Some(facet);
replan = false;
Ok(())
}
Event::Decide(facet, decision) => {
if let Some(decision) = decision {
if !self.editable(project, &facet)
&& !(decision == SemanticDecision::KeepTarget
&& self.can_acknowledge_constructor(project, &facet))
{
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
} else {
self.decisions.insert(facet, decision);
Ok(())
}
} else {
self.decisions.remove(&facet);
Ok(())
}
}
Event::Input(path, value) => self.edit(project, &path, |d| {
d.input(&path, value);
Ok(())
}),
Event::Text(path, action) => self.edit(project, &path, |d| {
d.edit_text(&path, action);
Ok(())
}),
Event::Shape(path, value) => {
self.edit(project, &path, |d| d.shape_scoped(&path, value))
}
Event::Variant(path, value) => self.edit(project, &path, |d| {
if d.value
.pointer(&path)
.is_some_and(|v| crate::forms::same_variant(v, &value))
{
Ok(())
} else {
d.shape_scoped(&path, value)
}
}),
Event::Array(path, index, direction) => {
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
}
Event::Append(path, value) => {
self.edit(project, &path, |d| d.append_scoped(&path, value))
}
Event::ValuePath(path, key) => {
self.edit(project, &path, |d| d.value_path_scoped(&path, key))
}
Event::Tab(tab) => {
self.tab = tab;
replan = false;
Ok(())
}
Event::ShowUnchanged(show) => {
self.show_unchanged = show;
replan = false;
Ok(())
}
Event::ReferenceOa(choice) => {
self.reference_oa = Some(choice);
replan = false;
Ok(())
}
Event::ReferenceSa(choice) => {
self.reference_sa = Some(choice);
replan = false;
Ok(())
}
Event::ReferenceSearch(value) => {
self.reference_search = value;
replan = false;
Ok(())
}
Event::AddReference => match (&self.reference_oa, &self.reference_sa) {
(Some(oa), Some(sa)) => {
self.references.insert(oa.id, sa.id);
Ok(())
}
_ => Err("Choose both an Operational and a System reference.".into()),
},
Event::RemoveReference(id) => {
self.references.remove(&id);
Ok(())
}
Event::AcceptImpact(index, accepted) => {
if let Some(impact) = self
.plan
.as_ref()
.and_then(|p| p.impacts.get(index))
.cloned()
{
self.accepted_impacts.retain(|i| i.object != impact.object);
if accepted {
self.accepted_impacts.push(impact);
}
}
self.refresh(project);
return;
}
Event::Preview => Ok(()),
};
if let Err(error) = result {
self.error = Some(error);
self.plan = None;
self.proposed = None;
return;
}
if replan {
self.accepted_impacts.clear();
self.refresh(project);
}
}
fn edit(
&mut self,
project: &Project,
path: &str,
action: impl FnOnce(&mut Draft) -> Result<(), String>,
) -> Result<(), String> {
let facet = self.selected.as_ref().ok_or("Choose a property first.")?;
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
return Err("Choose Set final value before editing this property.".into());
}
let root = 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}/")) {
return Err("Only the selected scalar property can change in this draft.".into());
}
let draft = self.draft.as_mut().ok_or("Choose an existing target.")?;
// The scoped view sends only descendants of the selected scalar property.
// 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());
}
action(draft)
}
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
self.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.is_some()
&& !scalar::grouped(facet)
}
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
if !scalar::grouped(facet) {
return false;
}
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
return false;
};
if trace
.baseline
.as_ref()
.and_then(|b| b.semantic.as_ref())
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == *facet))
{
return false;
}
self.facts
.iter()
.find(|f| f.selector == *facet)
.is_some_and(|f| {
scalar::value(&f.current, Layer::Oa).is_some()
&& scalar::observation(&f.current, Layer::Oa)
== scalar::observation(&f.current, Layer::Sa)
})
}
fn request(&self, project: &Project) -> Result<SemanticTransitionRequest, String> {
let trace = self
.trace
.and_then(|id| project.traces.get(&id))
.ok_or("Choose the trace whose review history you want to update.")?;
let (source, target) = if self.direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
} else {
(trace.sa, trace.oa)
};
let mut transition = TransitionRequest::new(self.direction, vec![source]);
transition.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
let mut request = SemanticTransitionRequest::new(transition);
request.traces.insert(source, trace.id);
request.references = self.references.clone();
request.reviews.push(SemanticReviewRequest {
source,
resolutions: self
.decisions
.iter()
.map(|(facet, decision)| FacetResolution {
facet: facet.clone(),
decision: *decision,
})
.collect(),
reset: vec![],
});
request.accepted_impacts = self.accepted_impacts.clone();
if self
.decisions
.values()
.any(|d| *d == SemanticDecision::Candidate)
{
let final_value = self.combined_value(project, false)?;
let candidate = if project.elements.contains_key(&target) {
SemanticRecord::Element(
serde_json::from_value(final_value)
.map_err(|e| format!("Final target value: {e}"))?,
)
} else {
SemanticRecord::Relation(
serde_json::from_value(final_value)
.map_err(|e| format!("Final target value: {e}"))?,
)
};
request.candidates.push(candidate);
}
Ok(request)
}
/// Compose selected scalar 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> {
let trace = self
.trace
.and_then(|id| project.traces.get(&id))
.ok_or("Choose a trace.")?;
let (source, target) = if self.direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
} else {
(trace.sa, trace.oa)
};
let mut final_value = Draft::from_project(project, target)
.ok_or("Target has no editable record.")?
.value;
let draft = self.draft.as_ref().ok_or("Target draft is unavailable.")?;
let mut map = BTreeMap::new();
for (oa, sa) in &self.references {
let (from, to) = if self.direction == TransitionDirection::OaToSa {
(*oa, *sa)
} else {
(*sa, *oa)
};
if map.insert(from, to).is_some_and(|old| old != to) {
return Err("Reverse reference correspondence is ambiguous.".into());
}
}
map.insert(source, target);
for (facet, decision) in &self.decisions {
if *decision == SemanticDecision::KeepTarget {
continue;
}
let path = scalar::binding(project, target, facet)
.ok_or("Selected property is not applicable to the target.")?;
let value = match decision {
SemanticDecision::KeepTarget => continue,
SemanticDecision::Candidate => {
let value = match draft.materialize_scopes(std::slice::from_ref(&path)) {
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 => {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::source_value(&fact, self.direction.source_layer(), &map)?
}
};
*final_value
.pointer_mut(&path)
.ok_or("Final target property is unavailable.")? = value;
}
Ok(final_value)
}
pub fn refresh(&mut self, project: &Project) {
self.plan = None;
self.proposed = None;
self.error = None;
let context = self.combined_value(project, true).ok();
if let Some(draft) = &mut self.draft {
draft.set_context(context);
}
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) 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,
Err(error) => {
self.error = Some(error);
return;
}
}
if self
.selected
.as_ref()
.is_none_or(|s| !self.facts.iter().any(|f| &f.selector == s))
{
self.selected = self
.facts
.iter()
.find(|f| f.drift != Drift::Unchanged)
.or(self.facts.first())
.map(|f| f.selector.clone());
}
let request = match self.request(project) {
Ok(request) => request,
Err(error) => {
self.error = Some(error);
return;
}
};
match plan_semantic_transition(project, request) {
Ok(plan) => {
if plan.conflicts.is_empty() {
match prepare_semantic_transition(project, &plan) {
Ok(prepared) => self.proposed = Some(prepared.project),
Err(error) => self.error = Some(error.to_string()),
}
}
self.plan = Some(plan);
}
Err(error) => self.error = Some(error.to_string()),
}
}
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 let Some(error) = &self.error {
return Err(error.clone());
}
let plan = self
.plan
.as_ref()
.ok_or("Refresh the review preview before applying.")?;
prepare_semantic_transition(project, plan).map_err(|e| e.to_string())?;
Ok(Command::Batch(plan.commands.clone()))
}
pub fn can_apply(&self) -> bool {
!self.decisions.is_empty()
&& self.error.is_none()
&& self
.plan
.as_ref()
.is_some_and(|p| p.conflicts.is_empty() && p.prepared_fingerprint.is_some())
}
pub fn all_references(&self, project: &Project, layer: Layer) -> Vec<Choice> {
let historical: BTreeSet<_> = self
.facts
.iter()
.flat_map(|f| scalar::references(&f.current))
.chain(
self.trace
.and_then(|id| project.traces.get(&id))
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.into_iter()
.flat_map(|b| b.facets.iter().flat_map(scalar::references)),
)
.collect();
let mut ids: BTreeSet<_> = project
.elements
.keys()
.chain(project.relations.keys())
.chain(project.artifact_links.keys())
.filter(|id| project.layer_of(**id) == Some(layer))
.copied()
.collect();
ids.extend(historical.into_iter().filter(|id| !project.contains(*id)));
let search = self.reference_search.to_lowercase();
let mut choices: Vec<_> = ids
.into_iter()
.map(|id| view::choice(project, id))
.filter(|c| search.is_empty() || c.label.to_lowercase().contains(&search))
.collect();
choices.sort_by(|a, b| a.label.cmp(&b.label));
choices
}
}
pub fn form_event(message: Message) -> Message {
let event = match message {
Message::Field(path, value) => Event::Input(path, value),
Message::FieldText(path, action) => Event::Text(path, action),
Message::FieldShape(path, value) => Event::Shape(path, value),
Message::FieldVariant(path, value) => Event::Variant(path, value),
Message::FieldArray(path, index, direction) => Event::Array(path, index, direction),
Message::FieldAppend(path, value) => Event::Append(path, value),
Message::FieldValuePath(path, key) => Event::ValuePath(path, key),
_ => Event::Preview,
};
Message::Workflow(crate::workflow::Event::Semantic(event))
}