Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
935 lines
32 KiB
Rust
935 lines
32 KiB
Rust
//! Throwaway R3 experiment. Nothing in oa-core depends on this crate.
|
|
//! A carrier stores independently reviewed facets; it is NOT a past valid model.
|
|
use oa_core::*;
|
|
use serde::{Deserialize, Serialize};
|
|
pub mod typed_reviews;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub enum OwnRecord {
|
|
Element {
|
|
parent: Option<Id>,
|
|
body: ElementBody,
|
|
},
|
|
Relation {
|
|
body: RelationBody,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct MemberPair {
|
|
pub oa: Option<Id>,
|
|
pub sa: Option<Id>,
|
|
}
|
|
|
|
/// Deliberately finite proof subset, not the final exhaustive R3 catalog.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub enum Facet {
|
|
IntegerMinimum,
|
|
IntegerMaximum,
|
|
DataUnit,
|
|
FieldMinimum,
|
|
StateGuard,
|
|
Trigger(MemberPair),
|
|
Step(MemberPair),
|
|
StepsOrder,
|
|
/// Definition AND default form one explicit constructor replacement group.
|
|
DataConstructor,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct SemanticBaseline {
|
|
pub oa: OwnRecord,
|
|
pub sa: OwnRecord,
|
|
pub reviewed: Vec<Facet>,
|
|
pub orders: Vec<OrderCheckpoint>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum OrderedCollection {
|
|
Steps,
|
|
}
|
|
|
|
/// Order overlaps membership in the live Vec, so it needs a separate review value.
|
|
/// No observation IDs, history versions, or generic value representation.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct OrderCheckpoint {
|
|
pub collection: OrderedCollection,
|
|
pub oa: Vec<Id>,
|
|
pub sa: Vec<Id>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Decision {
|
|
TakeSource,
|
|
KeepTarget,
|
|
Candidate,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Resolution {
|
|
pub facet: Facet,
|
|
pub decision: Decision,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Review {
|
|
pub source: Id,
|
|
pub target: Id,
|
|
pub candidate: Option<OwnRecord>,
|
|
pub resolutions: Vec<Resolution>,
|
|
}
|
|
|
|
pub fn own(p: &Project, id: Id) -> OwnRecord {
|
|
if let Some(e) = p.elements.get(&id) {
|
|
OwnRecord::Element {
|
|
parent: e.parent,
|
|
body: e.body.clone(),
|
|
}
|
|
} else {
|
|
OwnRecord::Relation {
|
|
body: p.relations[&id].body.clone(),
|
|
}
|
|
}
|
|
}
|
|
fn shared(record: &OwnRecord) -> Result<&SharedBody, String> {
|
|
match record {
|
|
OwnRecord::Element { body, .. } => body.shared().ok_or("Not a shared body".into()),
|
|
_ => Err("Not an element".into()),
|
|
}
|
|
}
|
|
fn shared_mut(record: &mut OwnRecord) -> Result<&mut SharedBody, String> {
|
|
match record {
|
|
OwnRecord::Element { body, .. } => body.shared_mut().ok_or("Not a shared body".into()),
|
|
_ => Err("Not an element".into()),
|
|
}
|
|
}
|
|
fn list(record: &OwnRecord, trigger: bool) -> Result<&Vec<Id>, String> {
|
|
match (record, trigger) {
|
|
(
|
|
OwnRecord::Relation {
|
|
body: RelationBody::StateTransition { triggers, .. },
|
|
},
|
|
true,
|
|
) => Ok(triggers),
|
|
(OwnRecord::Element { body, .. }, false) => {
|
|
body.steps().ok_or("Not an ordered sequence".into())
|
|
}
|
|
_ => Err("Wrong member selector".into()),
|
|
}
|
|
}
|
|
fn list_mut(record: &mut OwnRecord, trigger: bool) -> Result<&mut Vec<Id>, String> {
|
|
match (record, trigger) {
|
|
(
|
|
OwnRecord::Relation {
|
|
body: RelationBody::StateTransition { triggers, .. },
|
|
},
|
|
true,
|
|
) => Ok(triggers),
|
|
(OwnRecord::Element { body, .. }, false) => {
|
|
body.steps_mut().ok_or("Not an ordered sequence".into())
|
|
}
|
|
_ => Err("Wrong member selector".into()),
|
|
}
|
|
}
|
|
|
|
/// Copy same-coordinate facet; mapped reference proposals use copy_member below.
|
|
fn copy(dst: &mut OwnRecord, src: &OwnRecord, facet: &Facet, oa: bool) -> Result<(), String> {
|
|
match facet {
|
|
Facet::IntegerMinimum | Facet::IntegerMaximum => {
|
|
let SharedBody::DataType {
|
|
definition: DataTypeDefinition::Integer { minimum, maximum },
|
|
..
|
|
} = shared(src)?
|
|
else {
|
|
return Err("Explicit constructor decision required".into());
|
|
};
|
|
let (minimum, maximum) = (*minimum, *maximum);
|
|
let SharedBody::DataType {
|
|
definition:
|
|
DataTypeDefinition::Integer {
|
|
minimum: dst_min,
|
|
maximum: dst_max,
|
|
},
|
|
..
|
|
} = shared_mut(dst)?
|
|
else {
|
|
return Err("Explicit constructor decision required".into());
|
|
};
|
|
if *facet == Facet::IntegerMinimum {
|
|
*dst_min = minimum;
|
|
} else {
|
|
*dst_max = maximum;
|
|
}
|
|
}
|
|
Facet::DataUnit => {
|
|
let SharedBody::DataType { unit, .. } = shared(src)? else {
|
|
return Err("Wrong facet".into());
|
|
};
|
|
let unit = unit.clone();
|
|
let SharedBody::DataType { unit: dst_unit, .. } = shared_mut(dst)? else {
|
|
return Err("Wrong facet".into());
|
|
};
|
|
*dst_unit = unit;
|
|
}
|
|
Facet::FieldMinimum => {
|
|
let SharedBody::DataField { minimum, .. } = shared(src)? else {
|
|
return Err("Wrong facet".into());
|
|
};
|
|
let minimum = *minimum;
|
|
let SharedBody::DataField {
|
|
minimum: dst_min, ..
|
|
} = shared_mut(dst)?
|
|
else {
|
|
return Err("Wrong facet".into());
|
|
};
|
|
*dst_min = minimum;
|
|
}
|
|
Facet::StateGuard => {
|
|
let OwnRecord::Relation {
|
|
body: RelationBody::StateTransition { guard, .. },
|
|
} = src
|
|
else {
|
|
return Err("Wrong facet".into());
|
|
};
|
|
let OwnRecord::Relation {
|
|
body:
|
|
RelationBody::StateTransition {
|
|
guard: dst_guard, ..
|
|
},
|
|
} = dst
|
|
else {
|
|
return Err("Wrong facet".into());
|
|
};
|
|
*dst_guard = guard.clone();
|
|
}
|
|
Facet::Trigger(pair) | Facet::Step(pair) => {
|
|
let id = if oa { pair.oa } else { pair.sa };
|
|
copy_member(dst, src, matches!(facet, Facet::Trigger(_)), id, id)?;
|
|
}
|
|
Facet::StepsOrder => *list_mut(dst, false)? = list(src, false)?.clone(),
|
|
Facet::DataConstructor => {
|
|
let SharedBody::DataType {
|
|
definition,
|
|
default,
|
|
..
|
|
} = shared(src)?
|
|
else {
|
|
return Err("Wrong constructor scope".into());
|
|
};
|
|
let (definition, default) = (definition.clone(), default.clone());
|
|
let SharedBody::DataType {
|
|
definition: dst_definition,
|
|
default: dst_default,
|
|
..
|
|
} = shared_mut(dst)?
|
|
else {
|
|
return Err("Wrong constructor scope".into());
|
|
};
|
|
*dst_definition = definition;
|
|
*dst_default = default;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn copy_member(
|
|
dst: &mut OwnRecord,
|
|
src: &OwnRecord,
|
|
trigger: bool,
|
|
src_id: Option<Id>,
|
|
dst_id: Option<Id>,
|
|
) -> Result<(), String> {
|
|
if src_id.is_none() && dst_id.is_none() {
|
|
return Err("Empty member identity".into());
|
|
}
|
|
let present =
|
|
src_id.is_some_and(|id| list(src, trigger).is_ok_and(|items| items.contains(&id)));
|
|
let Some(dst_id) = dst_id else {
|
|
return if present {
|
|
Err("Missing counterpart mapping".into())
|
|
} else {
|
|
Ok(())
|
|
};
|
|
};
|
|
let items = list_mut(dst, trigger)?;
|
|
if present && !items.contains(&dst_id) {
|
|
items.push(dst_id);
|
|
}
|
|
if !present {
|
|
items.retain(|id| *id != dst_id);
|
|
}
|
|
Ok(())
|
|
}
|
|
fn equal(a: &OwnRecord, b: &OwnRecord, facet: &Facet, oa: bool) -> Result<bool, String> {
|
|
let mut projected = a.clone();
|
|
copy(&mut projected, b, facet, oa)?;
|
|
Ok(projected == *a)
|
|
}
|
|
pub fn reviewed_changed(
|
|
base: &SemanticBaseline,
|
|
now: &OwnRecord,
|
|
facet: &Facet,
|
|
oa: bool,
|
|
) -> Result<Option<bool>, String> {
|
|
if !base.reviewed.contains(facet) {
|
|
return Ok(None);
|
|
}
|
|
if *facet == Facet::StepsOrder {
|
|
let checkpoint = base
|
|
.orders
|
|
.iter()
|
|
.find(|o| o.collection == OrderedCollection::Steps)
|
|
.ok_or("Missing order checkpoint")?;
|
|
return Ok(Some(
|
|
list(now, false)? != if oa { &checkpoint.oa } else { &checkpoint.sa },
|
|
));
|
|
}
|
|
equal(if oa { &base.oa } else { &base.sa }, now, facet, oa).map(|same| Some(!same))
|
|
}
|
|
|
|
/// One validated core candidate, then baseline assignment. No partial commit.
|
|
/// OA→SA only in this bounded experiment.
|
|
pub fn apply(
|
|
p: &Project,
|
|
base: &SemanticBaseline,
|
|
review: &Review,
|
|
) -> Result<(Project, SemanticBaseline), String> {
|
|
let source = own(p, review.source);
|
|
let current = own(p, review.target);
|
|
let mut target = current.clone();
|
|
let mut acknowledged = base.clone();
|
|
let mut seen = Vec::new();
|
|
if review
|
|
.resolutions
|
|
.iter()
|
|
.any(|r| r.facet == Facet::DataConstructor)
|
|
&& review
|
|
.resolutions
|
|
.iter()
|
|
.any(|r| matches!(r.facet, Facet::IntegerMinimum | Facet::IntegerMaximum))
|
|
{
|
|
return Err("Constructor and descendant facets overlap".into());
|
|
}
|
|
for resolution in &review.resolutions {
|
|
if seen.contains(&resolution.facet) {
|
|
return Err("Duplicate facet".into());
|
|
}
|
|
seen.push(resolution.facet.clone());
|
|
match resolution.decision {
|
|
Decision::KeepTarget => {}
|
|
Decision::TakeSource => match &resolution.facet {
|
|
Facet::Trigger(pair) | Facet::Step(pair) => copy_member(
|
|
&mut target,
|
|
&source,
|
|
matches!(resolution.facet, Facet::Trigger(_)),
|
|
pair.oa,
|
|
pair.sa,
|
|
)?,
|
|
Facet::StepsOrder => {
|
|
return Err("This probe requires an explicit final order".into());
|
|
}
|
|
facet => copy(&mut target, &source, facet, false)?,
|
|
},
|
|
Decision::Candidate => {
|
|
let candidate = review.candidate.as_ref().ok_or("Missing candidate")?;
|
|
if resolution.facet == Facet::StepsOrder {
|
|
let mut expected = list(&target, false)?.clone();
|
|
let mut proposed = list(candidate, false)?.clone();
|
|
expected.sort();
|
|
proposed.sort();
|
|
if expected != proposed || proposed.windows(2).any(|p| p[0] == p[1]) {
|
|
return Err(
|
|
"Final order must contain every surviving target member exactly once"
|
|
.into(),
|
|
);
|
|
}
|
|
}
|
|
copy(&mut target, candidate, &resolution.facet, false)?;
|
|
}
|
|
}
|
|
if resolution.facet == Facet::DataConstructor {
|
|
acknowledged.reviewed.retain(|f| {
|
|
!matches!(
|
|
f,
|
|
Facet::IntegerMinimum | Facet::IntegerMaximum | Facet::DataConstructor
|
|
)
|
|
});
|
|
}
|
|
if resolution.facet == Facet::StepsOrder {
|
|
acknowledged
|
|
.orders
|
|
.retain(|o| o.collection != OrderedCollection::Steps);
|
|
acknowledged.orders.push(OrderCheckpoint {
|
|
collection: OrderedCollection::Steps,
|
|
oa: list(&source, false)?.clone(),
|
|
sa: list(&target, false)?.clone(),
|
|
});
|
|
} else {
|
|
copy(&mut acknowledged.oa, &source, &resolution.facet, true)?;
|
|
copy(&mut acknowledged.sa, &target, &resolution.facet, false)?;
|
|
}
|
|
if !acknowledged.reviewed.contains(&resolution.facet) {
|
|
acknowledged.reviewed.push(resolution.facet.clone());
|
|
}
|
|
}
|
|
// Explicit candidate cannot smuggle changes to any unreviewed facet.
|
|
if let Some(candidate) = &review.candidate {
|
|
if *candidate != target {
|
|
return Err("Candidate changes an unselected facet".into());
|
|
}
|
|
}
|
|
let command = match target {
|
|
OwnRecord::Element { parent, body } => {
|
|
let mut e = p.elements[&review.target].clone();
|
|
e.parent = parent;
|
|
e.body = body;
|
|
Command::EditElement(review.target, e)
|
|
}
|
|
OwnRecord::Relation { body } => {
|
|
let mut r = p.relations[&review.target].clone();
|
|
r.body = body;
|
|
Command::EditRelation(review.target, r)
|
|
}
|
|
};
|
|
let prepared = prepare_command(p, &Command::Batch(vec![command])).map_err(|e| e.to_string())?;
|
|
Ok((prepared.project, acknowledged))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
pub(crate) fn add(
|
|
p: &mut Project,
|
|
kind: Kind,
|
|
layer: Layer,
|
|
name: &str,
|
|
parent: Option<Id>,
|
|
) -> Id {
|
|
p.add_element(kind, layer, name, parent).unwrap()
|
|
}
|
|
pub(crate) fn set(p: &mut Project, id: Id, body: SharedBody) {
|
|
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body;
|
|
}
|
|
pub(crate) fn valid(p: &Project) {
|
|
let errors: Vec<_> = validation::validate(p)
|
|
.into_iter()
|
|
.filter(|i| i.severity == validation::Severity::Error)
|
|
.collect();
|
|
assert!(errors.is_empty(), "{errors:?}");
|
|
}
|
|
fn baseline(p: &Project, oa: Id, sa: Id, reviewed: Vec<Facet>) -> SemanticBaseline {
|
|
let orders = if reviewed.contains(&Facet::StepsOrder) {
|
|
vec![OrderCheckpoint {
|
|
collection: OrderedCollection::Steps,
|
|
oa: list(&own(p, oa), false).unwrap().clone(),
|
|
sa: list(&own(p, sa), false).unwrap().clone(),
|
|
}]
|
|
} else {
|
|
vec![]
|
|
};
|
|
SemanticBaseline {
|
|
oa: own(p, oa),
|
|
sa: own(p, sa),
|
|
reviewed,
|
|
orders,
|
|
}
|
|
}
|
|
fn resolution(facet: Facet, decision: Decision) -> Resolution {
|
|
Resolution { facet, decision }
|
|
}
|
|
fn roundtrip(p: &Project, base: &SemanticBaseline) {
|
|
let json = serde_json::to_string(base).unwrap();
|
|
assert_eq!(
|
|
serde_json::from_str::<SemanticBaseline>(&json).unwrap(),
|
|
*base
|
|
);
|
|
assert_eq!(language::parse(&language::format(p)).unwrap(), *p);
|
|
assert_eq!(
|
|
serde_json::from_str::<Project>(&serde_json::to_string(p).unwrap()).unwrap(),
|
|
*p
|
|
);
|
|
}
|
|
fn evidence(name: &str, p: &Project, base: &SemanticBaseline) {
|
|
let folder =
|
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/r3-probe/evidence");
|
|
std::fs::create_dir_all(&folder).unwrap();
|
|
std::fs::write(
|
|
folder.join(format!("{name}-baseline.json")),
|
|
serde_json::to_string_pretty(base).unwrap(),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
folder.join(format!("{name}-candidate.arcadia")),
|
|
language::format(p),
|
|
)
|
|
.unwrap();
|
|
}
|
|
pub(crate) fn integer(minimum: i64, maximum: i64, unit: &str) -> SharedBody {
|
|
SharedBody::DataType {
|
|
definition: DataTypeDefinition::Integer {
|
|
minimum: Some(minimum),
|
|
maximum: Some(maximum),
|
|
},
|
|
default: None,
|
|
unit: unit.into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bounded_data_partial_keep_take_and_constructor_atomicity() {
|
|
let mut p = Project::empty();
|
|
let oa = add(&mut p, Kind::DataType, Layer::Oa, "OA bounded", None);
|
|
let sa = add(&mut p, Kind::DataType, Layer::Sa, "SA bounded", None);
|
|
set(&mut p, oa, integer(0, 100, "m"));
|
|
set(&mut p, sa, integer(0, 100, "ft"));
|
|
let base = baseline(
|
|
&p,
|
|
oa,
|
|
sa,
|
|
vec![
|
|
Facet::IntegerMinimum,
|
|
Facet::IntegerMaximum,
|
|
Facet::DataUnit,
|
|
],
|
|
);
|
|
set(&mut p, oa, integer(120, 200, "cm"));
|
|
let review = Review {
|
|
source: oa,
|
|
target: sa,
|
|
candidate: None,
|
|
resolutions: vec![
|
|
resolution(Facet::IntegerMinimum, Decision::KeepTarget),
|
|
resolution(Facet::DataUnit, Decision::KeepTarget),
|
|
],
|
|
};
|
|
let (kept, mixed) = apply(&p, &base, &review).unwrap();
|
|
assert_eq!(kept, p);
|
|
// OA baseline now min=120,max=100. This is intentional: fields were reviewed independently.
|
|
assert!(matches!(
|
|
shared(&mixed.oa).unwrap(),
|
|
SharedBody::DataType {
|
|
definition: DataTypeDefinition::Integer {
|
|
minimum: Some(120),
|
|
maximum: Some(100)
|
|
},
|
|
..
|
|
}
|
|
));
|
|
assert_eq!(
|
|
reviewed_changed(&mixed, &own(&p, oa), &Facet::IntegerMinimum, true).unwrap(),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
reviewed_changed(&mixed, &own(&p, oa), &Facet::IntegerMaximum, true).unwrap(),
|
|
Some(true)
|
|
);
|
|
let take = Review {
|
|
source: oa,
|
|
target: sa,
|
|
candidate: None,
|
|
resolutions: vec![resolution(Facet::IntegerMaximum, Decision::TakeSource)],
|
|
};
|
|
let (p, merged) = apply(&p, &mixed, &take).unwrap();
|
|
assert!(
|
|
matches!(shared(&own(&p, sa)).unwrap(), SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(200) }, unit, .. } if unit == "ft")
|
|
);
|
|
let (again, same) = apply(&p, &merged, &take).unwrap();
|
|
assert_eq!(again, p);
|
|
assert_eq!(same, merged);
|
|
roundtrip(&p, &mixed);
|
|
evidence("mixed-range", &p, &mixed);
|
|
|
|
let mut fields = p;
|
|
let owner_oa = add(&mut fields, Kind::DataType, Layer::Oa, "OA record", None);
|
|
let owner_sa = add(&mut fields, Kind::DataType, Layer::Sa, "SA record", None);
|
|
for id in [owner_oa, owner_sa] {
|
|
set(
|
|
&mut fields,
|
|
id,
|
|
SharedBody::DataType {
|
|
definition: DataTypeDefinition::Record { supertype: None },
|
|
default: None,
|
|
unit: String::new(),
|
|
},
|
|
);
|
|
}
|
|
let field_oa = add(
|
|
&mut fields,
|
|
Kind::DataField,
|
|
Layer::Oa,
|
|
"OA field",
|
|
Some(owner_oa),
|
|
);
|
|
let field_sa = add(
|
|
&mut fields,
|
|
Kind::DataField,
|
|
Layer::Sa,
|
|
"SA field",
|
|
Some(owner_sa),
|
|
);
|
|
for (id, max) in [(field_oa, 5), (field_sa, 9)] {
|
|
set(
|
|
&mut fields,
|
|
id,
|
|
SharedBody::DataField {
|
|
type_ref: TypeRef::Named(if id == field_oa { oa } else { sa }),
|
|
minimum: 0,
|
|
maximum: Some(max),
|
|
unit: String::new(),
|
|
direction: ParameterDirection::Unspecified,
|
|
storage: FieldStorage::Value,
|
|
default: None,
|
|
},
|
|
);
|
|
}
|
|
let field_base = baseline(&fields, field_oa, field_sa, vec![Facet::FieldMinimum]);
|
|
if let Some(SharedBody::DataField { minimum, .. }) =
|
|
fields.elements.get_mut(&field_oa).unwrap().shared_mut()
|
|
{
|
|
*minimum = 2;
|
|
}
|
|
let (fields, field_base) = apply(
|
|
&fields,
|
|
&field_base,
|
|
&Review {
|
|
source: field_oa,
|
|
target: field_sa,
|
|
candidate: None,
|
|
resolutions: vec![resolution(Facet::FieldMinimum, Decision::TakeSource)],
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
fields.elements[&field_sa].shared(),
|
|
Some(SharedBody::DataField {
|
|
minimum: 2,
|
|
maximum: Some(9),
|
|
..
|
|
})
|
|
));
|
|
roundtrip(&fields, &field_base);
|
|
|
|
let mut switched = fields.clone();
|
|
let constructor_base = baseline(&switched, owner_oa, owner_sa, vec![]);
|
|
// Remove only the source's child; the target-only field remains real independent work.
|
|
switched = prepare_command(&switched, &Command::Delete(field_oa))
|
|
.unwrap()
|
|
.project;
|
|
set(&mut switched, owner_oa, integer(0, 10, ""));
|
|
valid(&switched);
|
|
let before = switched.clone();
|
|
let before_base = constructor_base.clone();
|
|
let result = apply(
|
|
&switched,
|
|
&constructor_base,
|
|
&Review {
|
|
source: owner_oa,
|
|
target: owner_sa,
|
|
candidate: None,
|
|
resolutions: vec![resolution(Facet::DataConstructor, Decision::TakeSource)],
|
|
},
|
|
);
|
|
assert!(
|
|
result.is_err(),
|
|
"target Record child blocks primitive constructor switch"
|
|
);
|
|
assert_eq!(switched, before);
|
|
assert_eq!(constructor_base, before_base);
|
|
let forbidden = apply(
|
|
&switched,
|
|
&constructor_base,
|
|
&Review {
|
|
source: owner_oa,
|
|
target: owner_sa,
|
|
candidate: None,
|
|
resolutions: vec![resolution(Facet::IntegerMinimum, Decision::TakeSource)],
|
|
},
|
|
);
|
|
assert!(forbidden.unwrap_err().contains("constructor"));
|
|
println!(
|
|
"bounded data: mixed historical min/max permitted; unselected drift retained; target max/unit preserved; constructor candidate rejected atomically"
|
|
);
|
|
}
|
|
|
|
pub(crate) fn state_side(p: &mut Project, layer: Layer, prefix: &str) -> (Id, Id, Id) {
|
|
let owner = add(
|
|
p,
|
|
if layer == Layer::Oa {
|
|
Kind::Entity
|
|
} else {
|
|
Kind::System
|
|
},
|
|
layer,
|
|
&format!("{prefix} owner"),
|
|
None,
|
|
);
|
|
let machine = add(
|
|
p,
|
|
Kind::StateMachine,
|
|
layer,
|
|
&format!("{prefix} machine"),
|
|
Some(owner),
|
|
);
|
|
let region = add(
|
|
p,
|
|
Kind::Region,
|
|
layer,
|
|
&format!("{prefix} region"),
|
|
Some(machine),
|
|
);
|
|
let a = add(p, Kind::State, layer, &format!("{prefix} A"), Some(region));
|
|
let b = add(p, Kind::State, layer, &format!("{prefix} B"), Some(region));
|
|
let old = add(p, Kind::Event, layer, &format!("{prefix} old event"), None);
|
|
let new = add(p, Kind::Event, layer, &format!("{prefix} new event"), None);
|
|
let relation = p.relate(
|
|
RelationKind::StateTransition,
|
|
&format!("{prefix} transition"),
|
|
a,
|
|
b,
|
|
);
|
|
if let RelationBody::StateTransition {
|
|
triggers, guard, ..
|
|
} = &mut p.relations.get_mut(&relation).unwrap().body
|
|
{
|
|
*triggers = vec![old];
|
|
*guard = "ready".into();
|
|
}
|
|
(relation, old, new)
|
|
}
|
|
#[test]
|
|
fn state_guard_and_trigger_delta_retain_deleted_historical_identity() {
|
|
let mut p = Project::empty();
|
|
let (oa, old_oa, new_oa) = state_side(&mut p, Layer::Oa, "OA");
|
|
let (sa, old_sa, new_sa) = state_side(&mut p, Layer::Sa, "SA");
|
|
let extra = add(&mut p, Kind::Event, Layer::Sa, "Target only", None);
|
|
let old = MemberPair {
|
|
oa: Some(old_oa),
|
|
sa: Some(old_sa),
|
|
};
|
|
let new = MemberPair {
|
|
oa: Some(new_oa),
|
|
sa: Some(new_sa),
|
|
};
|
|
let base = baseline(
|
|
&p,
|
|
oa,
|
|
sa,
|
|
vec![Facet::StateGuard, Facet::Trigger(old), Facet::Trigger(new)],
|
|
);
|
|
p = prepare_command(&p, &Command::Delete(old_oa))
|
|
.unwrap()
|
|
.project;
|
|
if let RelationBody::StateTransition {
|
|
triggers, guard, ..
|
|
} = &mut p.relations.get_mut(&oa).unwrap().body
|
|
{
|
|
*triggers = vec![new_oa];
|
|
*guard = "armed".into();
|
|
}
|
|
if let RelationBody::StateTransition {
|
|
triggers, guard, ..
|
|
} = &mut p.relations.get_mut(&sa).unwrap().body
|
|
{
|
|
triggers.push(extra);
|
|
*guard = "local inhibit clear".into();
|
|
}
|
|
valid(&p);
|
|
assert!(!p.elements.contains_key(&old_oa));
|
|
assert!(list(&base.oa, true).unwrap().contains(&old_oa));
|
|
// Current source uses live @alias semantics. A historical body needs a
|
|
// distinct source context, not transplantation into a live declaration.
|
|
let mut historical_as_live = p.clone();
|
|
if let OwnRecord::Relation { body } = &base.oa {
|
|
historical_as_live.relations.get_mut(&oa).unwrap().body = body.clone();
|
|
}
|
|
let rejected = language::parse(&language::format(&historical_as_live));
|
|
assert!(
|
|
rejected.is_err(),
|
|
"deleted historical UUID is not a live alias"
|
|
);
|
|
let review = Review {
|
|
source: oa,
|
|
target: sa,
|
|
candidate: None,
|
|
resolutions: vec![
|
|
resolution(Facet::StateGuard, Decision::KeepTarget),
|
|
resolution(Facet::Trigger(old), Decision::TakeSource),
|
|
resolution(Facet::Trigger(new), Decision::TakeSource),
|
|
],
|
|
};
|
|
let (p, acknowledged) = apply(&p, &base, &review).unwrap();
|
|
assert_eq!(list(&own(&p, sa), true).unwrap(), &vec![extra, new_sa]);
|
|
assert!(p.elements.contains_key(&old_sa));
|
|
assert!(
|
|
matches!(&p.relations[&sa].body, RelationBody::StateTransition { guard, .. } if guard == "local inhibit clear")
|
|
);
|
|
roundtrip(&p, &base);
|
|
roundtrip(&p, &acknowledged);
|
|
evidence("deleted-trigger", &p, &base);
|
|
let (again, same) = apply(&p, &acknowledged, &review).unwrap();
|
|
assert_eq!(again, p);
|
|
assert_eq!(same, acknowledged);
|
|
println!(
|
|
"state: old source Event deleted; historical member pair still removes its target link, preserves target object and extra trigger, keeps guard"
|
|
);
|
|
}
|
|
|
|
pub(crate) fn sequence(p: &mut Project, layer: Layer, prefix: &str) -> (Id, Id, Id) {
|
|
let sequence = add(
|
|
p,
|
|
if layer == Layer::Oa {
|
|
Kind::Process
|
|
} else {
|
|
Kind::FunctionalChain
|
|
},
|
|
layer,
|
|
prefix,
|
|
None,
|
|
);
|
|
let a = add(
|
|
p,
|
|
Kind::Occurrence,
|
|
layer,
|
|
&format!("{prefix} A"),
|
|
Some(sequence),
|
|
);
|
|
let b = add(
|
|
p,
|
|
Kind::Occurrence,
|
|
layer,
|
|
&format!("{prefix} B"),
|
|
Some(sequence),
|
|
);
|
|
for id in [a, b] {
|
|
set(
|
|
p,
|
|
id,
|
|
SharedBody::Occurrence {
|
|
body: OccurrenceBody::Control {
|
|
kind: ControlKind::Choice,
|
|
},
|
|
precondition: String::new(),
|
|
postcondition: String::new(),
|
|
},
|
|
);
|
|
}
|
|
*p.elements.get_mut(&sequence).unwrap().steps_mut().unwrap() = vec![a, b];
|
|
(sequence, a, b)
|
|
}
|
|
#[test]
|
|
fn ordered_steps_preserve_target_only_members_and_reject_hidden_edits() {
|
|
let mut p = Project::empty();
|
|
let (oa, oa_a, oa_b) = sequence(&mut p, Layer::Oa, "OA flow");
|
|
let (sa, sa_a, sa_b) = sequence(&mut p, Layer::Sa, "SA flow");
|
|
let base = baseline(&p, oa, sa, vec![Facet::StepsOrder]);
|
|
// Counterexample: a bare carrier loses pending placement review after
|
|
// independently acknowledging append membership. The order checkpoint
|
|
// keeps the exact older order and therefore retains the drift.
|
|
let oa_c = Id::new_v4();
|
|
let sa_c = Id::new_v4();
|
|
let mut source_with_append = own(&p, oa);
|
|
list_mut(&mut source_with_append, false).unwrap().push(oa_c);
|
|
let mut membership_only = base.clone();
|
|
copy(
|
|
&mut membership_only.oa,
|
|
&source_with_append,
|
|
&Facet::Step(MemberPair {
|
|
oa: Some(oa_c),
|
|
sa: Some(sa_c),
|
|
}),
|
|
true,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
membership_only.oa, source_with_append,
|
|
"bare carrier would falsely report no order drift"
|
|
);
|
|
assert_eq!(
|
|
reviewed_changed(
|
|
&membership_only,
|
|
&source_with_append,
|
|
&Facet::StepsOrder,
|
|
true
|
|
)
|
|
.unwrap(),
|
|
Some(true)
|
|
);
|
|
let extra = add(
|
|
&mut p,
|
|
Kind::Occurrence,
|
|
Layer::Sa,
|
|
"Target only step",
|
|
Some(sa),
|
|
);
|
|
set(
|
|
&mut p,
|
|
extra,
|
|
SharedBody::Occurrence {
|
|
body: OccurrenceBody::Control {
|
|
kind: ControlKind::Merge,
|
|
},
|
|
precondition: String::new(),
|
|
postcondition: String::new(),
|
|
},
|
|
);
|
|
*p.elements.get_mut(&sa).unwrap().steps_mut().unwrap() = vec![sa_a, extra, sa_b];
|
|
*p.elements.get_mut(&oa).unwrap().steps_mut().unwrap() = vec![oa_b, oa_a];
|
|
valid(&p);
|
|
let take = Review {
|
|
source: oa,
|
|
target: sa,
|
|
candidate: None,
|
|
resolutions: vec![resolution(Facet::StepsOrder, Decision::TakeSource)],
|
|
};
|
|
assert!(
|
|
apply(&p, &base, &take)
|
|
.unwrap_err()
|
|
.contains("explicit final order")
|
|
);
|
|
let mut candidate = own(&p, sa);
|
|
*list_mut(&mut candidate, false).unwrap() = vec![sa_b, sa_a];
|
|
let mut review = Review {
|
|
candidate: Some(candidate),
|
|
resolutions: vec![resolution(Facet::StepsOrder, Decision::Candidate)],
|
|
..take
|
|
};
|
|
assert!(
|
|
apply(&p, &base, &review)
|
|
.unwrap_err()
|
|
.contains("every surviving")
|
|
);
|
|
*list_mut(review.candidate.as_mut().unwrap(), false).unwrap() = vec![sa_b, extra, sa_a];
|
|
let target_only = p.elements[&extra].clone();
|
|
let (p, acknowledged) = apply(&p, &base, &review).unwrap();
|
|
assert_eq!(list(&own(&p, sa), false).unwrap(), &vec![sa_b, extra, sa_a]);
|
|
assert_eq!(p.elements[&extra], target_only);
|
|
roundtrip(&p, &acknowledged);
|
|
evidence("target-only-step", &p, &acknowledged);
|
|
let mut duplicate = review.clone();
|
|
duplicate.resolutions.push(duplicate.resolutions[0].clone());
|
|
assert!(
|
|
apply(&p, &acknowledged, &duplicate)
|
|
.unwrap_err()
|
|
.contains("Duplicate")
|
|
);
|
|
let mut hidden = review;
|
|
if let Some(OwnRecord::Element { parent, .. }) = &mut hidden.candidate {
|
|
*parent = Some(extra);
|
|
}
|
|
assert!(
|
|
apply(&p, &acknowledged, &hidden)
|
|
.unwrap_err()
|
|
.contains("unselected")
|
|
);
|
|
println!(
|
|
"steps: ambiguous source reorder demands explicit candidate; missing target-only member and unselected parent edits reject"
|
|
);
|
|
}
|
|
}
|