experiment: compare automatic layout against independent geometry metrics
This commit is contained in:
@@ -0,0 +1,671 @@
|
||||
//! Experimental observer of Command::Layout; no quality thresholds or candidate algorithm.
|
||||
use oa_core::{
|
||||
projection::{self, ProjectedView},
|
||||
routing, *,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
#[path = "auto_layout_probe/layered.rs"]
|
||||
mod layered;
|
||||
|
||||
type P = (f64, f64);
|
||||
type Rect = (f64, f64, f64, f64);
|
||||
const EPS: f64 = 0.0001;
|
||||
fn p(v: Position) -> P {
|
||||
(v.x as f64, v.y as f64)
|
||||
}
|
||||
fn sub(a: P, b: P) -> P {
|
||||
(a.0 - b.0, a.1 - b.1)
|
||||
}
|
||||
fn dot(a: P, b: P) -> f64 {
|
||||
a.0 * b.0 + a.1 * b.1
|
||||
}
|
||||
fn cross(a: P, b: P) -> f64 {
|
||||
a.0 * b.1 - a.1 * b.0
|
||||
}
|
||||
fn length(a: P) -> f64 {
|
||||
a.0.hypot(a.1)
|
||||
}
|
||||
fn rect(at: Position, size: Size) -> Rect {
|
||||
(
|
||||
at.x as f64,
|
||||
at.y as f64,
|
||||
(at.x + size.width) as f64,
|
||||
(at.y + size.height) as f64,
|
||||
)
|
||||
}
|
||||
fn overlap(a: Rect, b: Rect) -> f64 {
|
||||
let w = a.2.min(b.2) - a.0.max(b.0);
|
||||
let h = a.3.min(b.3) - a.1.max(b.1);
|
||||
if w > EPS && h > EPS { w * h } else { 0. }
|
||||
}
|
||||
fn contains(a: Rect, b: Rect) -> bool {
|
||||
a.0 <= b.0 + EPS && a.1 <= b.1 + EPS && a.2 + EPS >= b.2 && a.3 + EPS >= b.3
|
||||
}
|
||||
fn proper_cross(a: P, b: P, c: P, d: P) -> Option<P> {
|
||||
let u = sub(b, a);
|
||||
let v = sub(d, c);
|
||||
let denominator = cross(u, v);
|
||||
if denominator.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let t = cross(sub(c, a), v) / denominator;
|
||||
let s = cross(sub(c, a), u) / denominator;
|
||||
// Strict interiors: touches, T junctions and intersections at bends are not proper crossings.
|
||||
(t > 1e-9 && t < 1. - 1e-9 && s > 1e-9 && s < 1. - 1e-9)
|
||||
.then_some((a.0 + t * u.0, a.1 + t * u.1))
|
||||
}
|
||||
fn shared_length(a: P, b: P, c: P, d: P) -> f64 {
|
||||
let u = sub(b, a);
|
||||
let len = length(u);
|
||||
if len < EPS || cross(u, sub(c, a)).abs() / len > EPS || cross(u, sub(d, a)).abs() / len > EPS {
|
||||
return 0.;
|
||||
}
|
||||
let t = dot(sub(c, a), u) / len;
|
||||
let s = dot(sub(d, a), u) / len;
|
||||
(t.max(s).min(len) - t.min(s).max(0.)).max(0.)
|
||||
}
|
||||
fn ancestor(view: &ProjectedView, owner: Id, mut node: Id) -> bool {
|
||||
for _ in 0..view.nodes.len() {
|
||||
let Some(parent) = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == node)
|
||||
.and_then(|n| n.visual_parent)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if parent == owner {
|
||||
return true;
|
||||
}
|
||||
node = parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
fn terminal_points(
|
||||
edge: &projection::ProjectedEdge,
|
||||
points: &[Position],
|
||||
segment: usize,
|
||||
) -> Vec<(Id, P)> {
|
||||
let mut owners = Vec::new();
|
||||
if segment == 0 {
|
||||
owners.push((edge.source, p(points[0])));
|
||||
}
|
||||
if segment + 2 == points.len() {
|
||||
owners.push((edge.target, p(*points.last().unwrap())));
|
||||
}
|
||||
owners
|
||||
}
|
||||
fn inside_length(a: P, b: P, r: Rect) -> f64 {
|
||||
let delta = sub(b, a);
|
||||
let mut lo = 0_f64;
|
||||
let mut hi = 1_f64;
|
||||
for (origin, direction, min, max) in [
|
||||
(a.0, delta.0, r.0 + EPS, r.2 - EPS),
|
||||
(a.1, delta.1, r.1 + EPS, r.3 - EPS),
|
||||
] {
|
||||
if min >= max {
|
||||
return 0.;
|
||||
}
|
||||
if direction.abs() < 1e-9 {
|
||||
if origin <= min || origin >= max {
|
||||
return 0.;
|
||||
}
|
||||
} else {
|
||||
let t = (min - origin) / direction;
|
||||
let u = (max - origin) / direction;
|
||||
lo = lo.max(t.min(u));
|
||||
hi = hi.min(t.max(u));
|
||||
}
|
||||
}
|
||||
(hi - lo).max(0.) * length(delta)
|
||||
}
|
||||
fn header_height(project: &Project, diagram: Id, node: &projection::ProjectedNode) -> f64 {
|
||||
let h = if project.diagrams[&diagram]
|
||||
.projection
|
||||
.state_machine()
|
||||
.is_some()
|
||||
{
|
||||
projection::state_header_height(project, node.element)
|
||||
} else {
|
||||
64.
|
||||
};
|
||||
h.min(node.size.height) as f64
|
||||
}
|
||||
|
||||
fn metrics(project: &Project, diagram: Id) -> Value {
|
||||
let view = projection::project_view(project, diagram);
|
||||
let routed = routing::route_diagram(project, &project.diagrams[&diagram], &view);
|
||||
let edges: Vec<_> = routed.edges.values().collect();
|
||||
let definitions: BTreeMap<_, _> = view.edges.iter().map(|e| (e.id, e)).collect();
|
||||
let mut crossings = Vec::new();
|
||||
let mut shared = 0.;
|
||||
let mut excluded_shared = 0.;
|
||||
let mut excluded_stub_crossings = 0;
|
||||
for (i, a) in edges.iter().enumerate() {
|
||||
for b in edges.iter().skip(i + 1) {
|
||||
let mut unique = BTreeSet::new();
|
||||
for (ai, ab) in a.points.windows(2).enumerate() {
|
||||
for (bi, cd) in b.points.windows(2).enumerate() {
|
||||
let mut common_tips = Vec::new();
|
||||
for (owner, tip) in terminal_points(definitions[&a.id], &a.points, ai) {
|
||||
if terminal_points(definitions[&b.id], &b.points, bi)
|
||||
.iter()
|
||||
.any(|(other, at)| *other == owner && length(sub(*at, tip)) < EPS)
|
||||
{
|
||||
common_tips.push(tip);
|
||||
}
|
||||
}
|
||||
let crossing = proper_cross(p(ab[0]), p(ab[1]), p(cd[0]), p(cd[1]));
|
||||
let overlap = shared_length(p(ab[0]), p(ab[1]), p(cd[0]), p(cd[1]));
|
||||
// Raw totals stay primary. Only physically common tips get
|
||||
// bounded PORT_STUB exemptions, never a whole compacted segment.
|
||||
shared += overlap;
|
||||
excluded_shared +=
|
||||
overlap.min(common_tips.len() as f64 * routing::PORT_STUB as f64);
|
||||
if let Some(at) = crossing {
|
||||
if unique.insert(((at.0 / EPS).round() as i64, (at.1 / EPS).round() as i64))
|
||||
{
|
||||
let near_tip = common_tips.iter().any(|tip| {
|
||||
length(sub(at, *tip)) <= routing::PORT_STUB as f64 + EPS
|
||||
});
|
||||
excluded_stub_crossings += usize::from(near_tip);
|
||||
crossings.push(json!({"edges":[a.id,b.id],"at":[at.0,at.1],"within_common_terminal_stub":near_tip}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut node_collisions = Vec::new();
|
||||
let mut label_nodes = Vec::new();
|
||||
let mut label_labels = Vec::new();
|
||||
let mut excluded_ancestor_labels = 0;
|
||||
let mut penetrations = Vec::new();
|
||||
let mut label_strokes = Vec::new();
|
||||
for e in &edges {
|
||||
let def = definitions[&e.id];
|
||||
for n in &view.nodes {
|
||||
if n.id == def.source || n.id == def.target {
|
||||
continue;
|
||||
}
|
||||
let mut obstacle = rect(n.position, n.size);
|
||||
let ancestor_header =
|
||||
ancestor(&view, n.id, def.source) || ancestor(&view, n.id, def.target);
|
||||
if ancestor_header {
|
||||
obstacle.3 = obstacle.1 + header_height(project, diagram, n);
|
||||
}
|
||||
let length: f64 = e
|
||||
.points
|
||||
.windows(2)
|
||||
.map(|w| inside_length(p(w[0]), p(w[1]), obstacle))
|
||||
.sum();
|
||||
if length > EPS {
|
||||
penetrations.push(json!({"edge":e.id,"node":n.id,"length":length,"ancestor_header_only":ancestor_header}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, a) in view.nodes.iter().enumerate() {
|
||||
for b in view.nodes.iter().skip(i + 1) {
|
||||
let area = overlap(rect(a.position, a.size), rect(b.position, b.size));
|
||||
if area > 0. && !ancestor(&view, a.id, b.id) && !ancestor(&view, b.id, a.id) {
|
||||
node_collisions.push(json!({"nodes":[a.id,b.id],"intersection_area":area}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let labels: Vec<_> = edges.iter().filter(|e| !e.label.text.is_empty()).collect();
|
||||
for (i, e) in labels.iter().enumerate() {
|
||||
let lb = rect(e.label.bounds.position, e.label.bounds.size);
|
||||
let def = definitions[&e.id];
|
||||
for n in &view.nodes {
|
||||
let nb = rect(n.position, n.size);
|
||||
let area = overlap(lb, nb);
|
||||
if area == 0. {
|
||||
continue;
|
||||
}
|
||||
let body = (nb.0, nb.1 + header_height(project, diagram, n), nb.2, nb.3);
|
||||
if contains(body, lb)
|
||||
&& (ancestor(&view, n.id, def.source) || ancestor(&view, n.id, def.target))
|
||||
{
|
||||
excluded_ancestor_labels += 1;
|
||||
} else {
|
||||
label_nodes.push(json!({"edge":e.id,"node":n.id,"intersection_area":area}));
|
||||
}
|
||||
}
|
||||
for other in labels.iter().skip(i + 1) {
|
||||
let area = overlap(
|
||||
lb,
|
||||
rect(other.label.bounds.position, other.label.bounds.size),
|
||||
);
|
||||
if area > 0. {
|
||||
label_labels.push(json!({"edges":[e.id,other.id],"intersection_area":area}));
|
||||
}
|
||||
}
|
||||
for other in edges.iter().filter(|other| other.id != e.id) {
|
||||
let length: f64 = other
|
||||
.points
|
||||
.windows(2)
|
||||
.map(|w| inside_length(p(w[0]), p(w[1]), lb))
|
||||
.sum();
|
||||
if length > EPS {
|
||||
label_strokes
|
||||
.push(json!({"label_edge":e.id,"stroke_edge":other.id,"length":length}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut total_length = 0.;
|
||||
let mut bends = 0;
|
||||
for e in &edges {
|
||||
total_length += e
|
||||
.points
|
||||
.windows(2)
|
||||
.map(|w| length(sub(p(w[1]), p(w[0]))))
|
||||
.sum::<f64>();
|
||||
bends += e
|
||||
.points
|
||||
.windows(3)
|
||||
.filter(|w| {
|
||||
let a = sub(p(w[1]), p(w[0]));
|
||||
let b = sub(p(w[2]), p(w[1]));
|
||||
length(a) > EPS && length(b) > EPS && (cross(a, b).abs() > EPS || dot(a, b) < 0.)
|
||||
})
|
||||
.count();
|
||||
}
|
||||
let warnings: Vec<_> = edges
|
||||
.iter()
|
||||
.flat_map(|e| {
|
||||
e.warnings
|
||||
.iter()
|
||||
.map(move |w| json!({"edge":e.id,"warning":format!("{w:?}")}))
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"nodes":view.nodes.len(),"edges":edges.len(),"ports":view.ports.len(),
|
||||
"proper_crossings":crossings.len(),"crossing_details":crossings,
|
||||
"pairwise_collinear_shared_length":shared,
|
||||
"excluded_common_terminal_shared_length":excluded_shared,"excluded_common_terminal_crossings":excluded_stub_crossings,
|
||||
"nonterminal_proper_crossings":crossings.len()-excluded_stub_crossings,
|
||||
"nonterminal_pairwise_shared_length":shared-excluded_shared,
|
||||
"unrelated_edge_node_penetrations":penetrations.len(),"edge_node_details":penetrations,
|
||||
"unrelated_label_stroke_intersections":label_strokes.len(),"label_stroke_details":label_strokes,
|
||||
"unrelated_node_collisions":node_collisions.len(),"node_collision_details":node_collisions,
|
||||
"label_node_collisions":label_nodes.len(),"label_node_details":label_nodes,
|
||||
"excluded_contained_endpoint_ancestor_label_pairs":excluded_ancestor_labels,
|
||||
"label_label_collisions":label_labels.len(),"label_label_details":label_labels,
|
||||
"warning_count":warnings.len(),"warnings":warnings,
|
||||
"drawing_bounds":{"position":routed.bounds.position,"size":routed.bounds.size},
|
||||
"drawing_area":routed.bounds.size.width as f64*routed.bounds.size.height as f64,
|
||||
"route_bends":bends,"route_length":total_length,
|
||||
"projected_nodes":view.nodes,"projected_ports":view.ports,
|
||||
"routed_edges":edges.iter().map(|e|json!({"id":e.id,"points":e.points,"label":{
|
||||
"text":e.label.text,"position":e.label.bounds.position,"size":e.label.bounds.size}})).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
next: u128,
|
||||
witness: Option<Project>,
|
||||
}
|
||||
impl Builder {
|
||||
fn new(name: &str) -> Self {
|
||||
let mut project = Project::empty();
|
||||
project.id = Id::from_u128(90_000);
|
||||
project.alias = "layout_probe".into();
|
||||
project.name = name.into();
|
||||
let old = project.ensure_diagram(Layer::Sa);
|
||||
let diagram = Id::from_u128(90_001);
|
||||
reidentify(&mut project, old, diagram).unwrap();
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
Self {
|
||||
project,
|
||||
diagram,
|
||||
next: 1,
|
||||
witness: None,
|
||||
}
|
||||
}
|
||||
fn stable(&mut self, old: Id) -> Id {
|
||||
let id = Id::from_u128(self.next);
|
||||
self.next += 1;
|
||||
reidentify(&mut self.project, old, id).unwrap();
|
||||
id
|
||||
}
|
||||
fn function(&mut self, name: &str, parent: Option<Id>, x: f32, y: f32) -> Id {
|
||||
let old = self
|
||||
.project
|
||||
.add_element(Kind::Function, Layer::Sa, name, parent)
|
||||
.unwrap();
|
||||
let id = self.stable(old);
|
||||
let old = self.project.place(self.diagram, id, Position { x, y });
|
||||
self.stable(old);
|
||||
id
|
||||
}
|
||||
fn exchange(&mut self, name: &str, a: Id, b: Id) -> Id {
|
||||
let old = self
|
||||
.project
|
||||
.relate(RelationKind::FunctionalExchange, name, a, b);
|
||||
let edge = self.stable(old);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = self.project.relations[&edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
for (old, prefix) in [(source_port, "out"), (target_port, "in")] {
|
||||
let id = self.stable(old);
|
||||
self.project.elements.get_mut(&id).unwrap().alias =
|
||||
format!("{prefix}_{}", id.as_u128());
|
||||
}
|
||||
edge
|
||||
}
|
||||
fn node(&self, element: Id) -> Id {
|
||||
self.project.diagrams[&self.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == element)
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
}
|
||||
fn fixtures() -> Vec<(&'static str, Builder)> {
|
||||
let mut all = Vec::new();
|
||||
for (name, count) in [("flat_chain_4", 4), ("chain_7_row_wrap", 7)] {
|
||||
let mut f = Builder::new(name);
|
||||
let mut nodes = Vec::new();
|
||||
for i in 0..count {
|
||||
nodes.push(f.function(&format!("Function {i}"), None, 40. + 320. * i as f32, 80.));
|
||||
}
|
||||
for i in 0..count - 1 {
|
||||
f.exchange(&format!("Flow {i}"), nodes[i], nodes[i + 1]);
|
||||
}
|
||||
all.push((name, f));
|
||||
}
|
||||
for (name, links) in [
|
||||
("diamond", vec![(0, 1), (0, 2), (1, 3), (2, 3)]),
|
||||
("crossed_bipartite", vec![(0, 3), (1, 2), (0, 2), (1, 3)]),
|
||||
] {
|
||||
let mut f = Builder::new(name);
|
||||
let mut nodes = Vec::new();
|
||||
let coordinates = if name == "diamond" {
|
||||
[(40., 240.), (400., 80.), (400., 400.), (760., 240.)]
|
||||
} else {
|
||||
[(40., 80.), (40., 400.), (620., 80.), (620., 400.)]
|
||||
};
|
||||
for (i, (x, y)) in coordinates.into_iter().enumerate() {
|
||||
nodes.push(f.function(&format!("Node {i}"), None, x, y));
|
||||
}
|
||||
for (i, (a, b)) in links.into_iter().enumerate() {
|
||||
f.exchange(&format!("Flow {i}"), nodes[a], nodes[b]);
|
||||
}
|
||||
all.push((name, f));
|
||||
}
|
||||
let mut f = Builder::new("nested_cross_boundary");
|
||||
let group = f.function("Parent function", None, 40., 40.);
|
||||
let a = f.function("Child send", Some(group), 100., 130.);
|
||||
let b = f.function("Child receive", Some(group), 100., 330.);
|
||||
let c = f.function("Outside", None, 900., 210.);
|
||||
f.exchange("Internal flow", a, b);
|
||||
f.exchange("Cross boundary", b, c);
|
||||
all.push(("nested_cross_boundary", f));
|
||||
for fixed in [false, true] {
|
||||
let name = if fixed {
|
||||
"fixed_ports"
|
||||
} else {
|
||||
"authored_detour_and_label"
|
||||
};
|
||||
let mut f = Builder::new(name);
|
||||
let a = f.function("Sender", None, 40., 80.);
|
||||
let b = f.function("Receiver", None, 640., 80.);
|
||||
let edge = f.exchange("Preserved information", a, b);
|
||||
if fixed {
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = f.project.relations[&edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
for (element, port, side, offset) in [
|
||||
(a, source_port, PortAnchorSide::Left, 0.3),
|
||||
(b, target_port, PortAnchorSide::Right, 0.7),
|
||||
] {
|
||||
let node = f.node(element);
|
||||
f.project = prepare_command(
|
||||
&f.project,
|
||||
&Command::SetPortAnchor {
|
||||
diagram: f.diagram,
|
||||
node,
|
||||
anchor: PortAnchor { port, side, offset },
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
}
|
||||
} else {
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![
|
||||
Position { x: 400., y: -320. },
|
||||
Position { x: 1000., y: -320. },
|
||||
Position { x: 1000., y: 240. },
|
||||
],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.7,
|
||||
offset: Position { x: 100., y: -180. },
|
||||
}),
|
||||
});
|
||||
}
|
||||
all.push((name, f));
|
||||
}
|
||||
let mut f = Builder::new("two_containers_reversed_children");
|
||||
let left = f.function("Left group", None, 40., 40.);
|
||||
let right = f.function("Right group", None, 820., 40.);
|
||||
let la = f.function("Left A", Some(left), 100., 130.);
|
||||
let lb = f.function("Left B", Some(left), 100., 330.);
|
||||
let ra = f.function("Right A", Some(right), 880., 130.);
|
||||
let rb = f.function("Right B", Some(right), 880., 330.);
|
||||
f.exchange("Upper to lower", la, rb);
|
||||
f.exchange("Lower to upper", lb, ra);
|
||||
let mut witness = f.project.clone();
|
||||
for (element, y) in [(ra, 330.), (rb, 130.)] {
|
||||
let node = f.node(element);
|
||||
witness
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position
|
||||
.y = y;
|
||||
}
|
||||
f.witness = Some(witness);
|
||||
all.push(("two_containers_reversed_children", f));
|
||||
let mut f = Builder::new("long_ordinary_labels");
|
||||
let a = f.function("Input", None, 40., 100.);
|
||||
let b = f.function("Transform", None, 400., 100.);
|
||||
let c = f.function("Output", None, 760., 100.);
|
||||
f.exchange(
|
||||
"A very long first information exchange label exceeding the ordinary cutoff",
|
||||
a,
|
||||
b,
|
||||
);
|
||||
f.exchange(
|
||||
"A very long second information exchange label exceeding the ordinary cutoff",
|
||||
b,
|
||||
c,
|
||||
);
|
||||
f.exchange(
|
||||
"A very long bypass information exchange label exceeding the ordinary cutoff",
|
||||
a,
|
||||
c,
|
||||
);
|
||||
all.push(("long_ordinary_labels", f));
|
||||
all
|
||||
}
|
||||
|
||||
fn reset_effective_routes(project: &Project, diagram: Id) -> Project {
|
||||
let view = projection::project_view(project, diagram);
|
||||
let routed = routing::route_diagram(project, &project.diagrams[&diagram], &view);
|
||||
let mut reset = project.clone();
|
||||
for route in &mut reset.diagrams.get_mut(&diagram).unwrap().routes {
|
||||
if routed
|
||||
.edges
|
||||
.get(&route.edge)
|
||||
.is_some_and(|edge| !edge.custom_suspended)
|
||||
{
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
}
|
||||
}
|
||||
reset
|
||||
}
|
||||
|
||||
fn conservation(before: &Project, after: &Project, diagram: Id) -> Value {
|
||||
let mut semantic = after.clone();
|
||||
semantic.diagrams = before.diagrams.clone();
|
||||
let bd = &before.diagrams[&diagram];
|
||||
let ad = &after.diagrams[&diagram];
|
||||
let mut stable_view = ad.clone();
|
||||
stable_view.nodes = bd.nodes.clone();
|
||||
stable_view.routes = bd.routes.clone();
|
||||
stable_view.layout = bd.layout.clone();
|
||||
let mut other_views = after.diagrams.clone();
|
||||
other_views.insert(diagram, bd.clone());
|
||||
let node_metadata = bd.nodes.len() == ad.nodes.len()
|
||||
&& bd.nodes.iter().all(|(id, node)| {
|
||||
ad.nodes.get(id).is_some_and(|new| {
|
||||
let mut restored = new.clone();
|
||||
restored.position = node.position;
|
||||
restored.size = node.size;
|
||||
restored == *node
|
||||
})
|
||||
});
|
||||
json!({"semantic_records_exact":semantic==*before,"target_view_nongeometry_exact":stable_view==*bd,
|
||||
"other_views_exact":other_views==before.diagrams,"node_identity_visibility_anchors_exact":node_metadata,
|
||||
"authored_routes_exact":ad.routes==bd.routes})
|
||||
}
|
||||
|
||||
fn roundtrip(project: &Project) -> Value {
|
||||
let bytes = serde_json::to_vec(project).unwrap();
|
||||
let json_result = persistence::decode(&bytes);
|
||||
let source = language::format(project);
|
||||
let source_result = language::parse(&source);
|
||||
json!({"json_exact":json_result.as_ref().is_ok_and(|p|p==project),
|
||||
"json_error":json_result.err().map(|e|e.to_string()),
|
||||
"single_source_exact":source_result.as_ref().is_ok_and(|p|p==project),
|
||||
"source_error":source_result.err().map(|e|format!("{e:?}"))})
|
||||
}
|
||||
fn observe(name: &str, f: Builder) -> Value {
|
||||
let witness = f.witness;
|
||||
let before = f.project;
|
||||
let diagram = f.diagram;
|
||||
if let Err(error) = validate_candidate(&before) {
|
||||
return json!({"fixture":name,"fixture_error":error.to_string()});
|
||||
}
|
||||
let mut session = Session::new(before.clone());
|
||||
if let Err(error) = session.execute(Command::Layout(diagram)) {
|
||||
return json!({"fixture":name,"before":metrics(&before,diagram),"layout_error":error.to_string()});
|
||||
}
|
||||
let after = session.project().clone();
|
||||
let changed = after != before;
|
||||
let had_undo = session.can_undo();
|
||||
session.undo();
|
||||
let undo = session.project() == &before;
|
||||
let one_step = !session.can_undo();
|
||||
session.redo();
|
||||
let redo = session.project() == &after;
|
||||
let second = prepare_command(&after, &Command::Layout(diagram));
|
||||
let reset = reset_effective_routes(&after, diagram);
|
||||
let mut repeated = after.clone();
|
||||
let mut stability = Vec::new();
|
||||
for pass in 2..=10 {
|
||||
match prepare_command(&repeated, &Command::Layout(diagram)) {
|
||||
Ok(next) => {
|
||||
stability.push(json!({"pass":pass,"equal_previous":next.project==repeated,"equal_first":next.project==after}));
|
||||
repeated = next.project;
|
||||
}
|
||||
Err(error) => {
|
||||
stability.push(json!({"pass":pass,"error":error.to_string()}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let witness=witness.map(|p|json!({"metrics":metrics(&p,diagram),"project":p,
|
||||
"conservation":conservation(&before,&p,diagram),"validation_error":validate_candidate(&p).err().map(|e|e.to_string())}));
|
||||
let candidate = match layered::candidate(&before, diagram) {
|
||||
Err(error) => json!({"supported":false,"error":error.to_string()}),
|
||||
Ok(candidate) => {
|
||||
if let Err(error) = validate_candidate(&candidate) {
|
||||
json!({"supported":true,"validation_error":error.to_string(),"project":candidate})
|
||||
} else {
|
||||
let again = layered::candidate(&candidate, diagram);
|
||||
let duplicate = layered::candidate(&before, diagram);
|
||||
let mut repeated_candidate = candidate.clone();
|
||||
let mut candidate_stability = Vec::new();
|
||||
for pass in 2..=10 {
|
||||
match layered::candidate(&repeated_candidate, diagram) {
|
||||
Ok(next) => {
|
||||
candidate_stability.push(json!({"pass":pass,"equal_previous":next==repeated_candidate,"equal_first":next==candidate}));
|
||||
repeated_candidate = next;
|
||||
}
|
||||
Err(error) => {
|
||||
candidate_stability
|
||||
.push(json!({"pass":pass,"error":error.to_string()}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({"supported":true,"metrics":metrics(&candidate,diagram),"project":candidate,
|
||||
"conservation":conservation(&before,&candidate,diagram),"roundtrip":roundtrip(&candidate),
|
||||
"ten_candidate_calls":candidate_stability,
|
||||
"repeat_same_input_exact":duplicate.as_ref().is_ok_and(|p|*p==candidate),
|
||||
"idempotent":again.as_ref().is_ok_and(|p|*p==candidate),
|
||||
"repeat_error":duplicate.err().map(|e|e.to_string()),"idempotence_error":again.err().map(|e|e.to_string())})
|
||||
}
|
||||
}
|
||||
};
|
||||
json!({"fixture":name,"diagram":diagram,"before":metrics(&before,diagram),"after":metrics(&after,diagram),
|
||||
"before_project":before,"after_project":after,"conservation":conservation(&before,&after,diagram),
|
||||
"reset_only_control":{"metrics":metrics(&reset,diagram),"project":reset,"conservation":conservation(&before,&reset,diagram),"roundtrip":roundtrip(&reset)},
|
||||
"manual_planar_order_witness":witness,"ten_layout_calls":stability,"experimental_candidate":candidate,
|
||||
"checks":{
|
||||
"changed":changed,"history_entry_matches_change":had_undo==changed,"undo_exact":undo,"one_step_undo":one_step,"redo_exact":redo,
|
||||
"second_layout_exact":second.as_ref().is_ok_and(|s|s.project==after),"second_layout_error":second.err().map(|e|e.to_string()),
|
||||
"before_roundtrip":roundtrip(&before),"after_roundtrip":roundtrip(&after)}})
|
||||
}
|
||||
fn main() {
|
||||
// Analytic checks validate the observer, not any layout's quality.
|
||||
assert_eq!(
|
||||
proper_cross((0., 0.), (10., 0.), (5., -5.), (5., 5.)),
|
||||
Some((5., 0.))
|
||||
);
|
||||
assert_eq!(
|
||||
proper_cross((0., 0.), (10., 0.), (10., 0.), (10., 5.)),
|
||||
None
|
||||
);
|
||||
assert!((shared_length((0., 0.), (10., 0.), (4., 0.), (12., 0.)) - 6.).abs() < EPS);
|
||||
assert_eq!(shared_length((0., 0.), (10., 0.), (4., 1.), (12., 1.)), 0.);
|
||||
assert_eq!(overlap((0., 0., 10., 10.), (10., 0., 20., 10.)), 0.);
|
||||
assert!((inside_length((0., 5.), (10., 5.), (2., 2., 8., 8.)) - 6.).abs() < 3. * EPS);
|
||||
assert_eq!(inside_length((0., 2.), (10., 2.), (2., 2., 8., 8.)), 0.);
|
||||
println!(
|
||||
"{}",
|
||||
json!({"probe":"auto_layout_probe_v1","reference_source":"715036e132677dd2bc2fb37ed316d93dc9987aec","metric_selfchecks":"passed", "quality_thresholds":null,
|
||||
"exclusions":"Raw strict-interior crossings and pairwise shared length are primary; separately bound common physical endpoint exclusions to PORT_STUB. Ancestor node containment excluded. Labels wholly inside endpoint-ancestor BODY below header exempt; headers retained. Penetration excludes endpoint nodes and endpoint-ancestor bodies. Label/stroke excludes own edge. Core approximate label bounds, no glyph/port-label metrics."})
|
||||
);
|
||||
for (name, f) in fixtures() {
|
||||
println!("{}", observe(name, f));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Isolated #32 experiment, not a production layout engine.
|
||||
//! Uses existing Layout sizing, three deterministic ordering passes, and one
|
||||
//! core-estimated-label spacing pass. Automatic elbows remain derived. Folded/omitted
|
||||
//! node topology is explicitly unsupported; filtered edges retain their metadata.
|
||||
use oa_core::{Command, Id, ModelError, Position, Project, Size, projection, routing};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const WORK_LIMIT: usize = 200_000;
|
||||
const NODE_LIMIT: usize = 256;
|
||||
const EDGE_LIMIT: usize = 1024;
|
||||
|
||||
fn spend(work: &mut usize) -> Result<(), ModelError> {
|
||||
*work += 1;
|
||||
if *work > WORK_LIMIT {
|
||||
return Err(ModelError("Layered probe work limit exceeded.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return a new Project; caller owns comparison, history and serialization.
|
||||
pub fn candidate(project: &Project, diagram: Id) -> Result<Project, ModelError> {
|
||||
let original = project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or_else(|| ModelError("Missing probe diagram.".into()))?;
|
||||
let view = projection::project_diagram(project, original);
|
||||
if view.nodes.len() > NODE_LIMIT || view.edges.len() > EDGE_LIMIT {
|
||||
return Err(ModelError("Layered probe graph limit exceeded.".into()));
|
||||
}
|
||||
if view.nodes.len() != original.nodes.len() || view.nodes.iter().any(|n| n.collapsed) {
|
||||
return Err(ModelError(
|
||||
"Layered probe does not support folded/omitted node topology.".into(),
|
||||
));
|
||||
}
|
||||
let routed = routing::route_diagram(project, original, &view);
|
||||
let eligible: BTreeSet<_> = view
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|edge| {
|
||||
projection::edge_endpoint_placements(project, original, edge.id)
|
||||
== Some((edge.source, edge.target))
|
||||
&& routed
|
||||
.edges
|
||||
.get(&edge.id)
|
||||
.is_some_and(|route| !route.custom_suspended)
|
||||
})
|
||||
.map(|edge| edge.id)
|
||||
.collect();
|
||||
let mut result = oa_core::prepare_command(project, &Command::Layout(diagram))?.project;
|
||||
// Discard baseline route translation: final routes are reconstructed directly
|
||||
// from original geometry, avoiding two translations and preserving list order.
|
||||
result.diagrams.get_mut(&diagram).unwrap().routes = original.routes.clone();
|
||||
for route in &mut result.diagrams.get_mut(&diagram).unwrap().routes {
|
||||
if eligible.contains(&route.edge) {
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
}
|
||||
}
|
||||
let baseline = projection::project_view(&result, diagram);
|
||||
let labels = routing::route_diagram(&result, &result.diagrams[&diagram], &baseline);
|
||||
let mut gap_x = 64.0_f32;
|
||||
let mut gap_y = 32.0_f32;
|
||||
for edge in labels.edges.values() {
|
||||
gap_x = gap_x.max(edge.label.bounds.size.width + 24.0);
|
||||
gap_y = gap_y.max(edge.label.bounds.size.height + 24.0);
|
||||
if let Some(relation) = result.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, edge) {
|
||||
gap_x = gap_x.max(end.label.bounds.size.width + 24.0);
|
||||
gap_y = gap_y.max(end.label.bounds.size.height + 24.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !gap_x.is_finite() || !gap_y.is_finite() {
|
||||
return Err(ModelError("Nonfinite estimated label bound.".into()));
|
||||
}
|
||||
let parents: BTreeMap<_, _> = baseline
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| (n.id, n.visual_parent))
|
||||
.collect();
|
||||
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
|
||||
for node in &baseline.nodes {
|
||||
children
|
||||
.entry(node.visual_parent)
|
||||
.or_default()
|
||||
.push(node.id);
|
||||
}
|
||||
let names: BTreeMap<_, _> = baseline
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| (n.id, (result.elements[&n.element].alias.clone(), n.id)))
|
||||
.collect();
|
||||
for siblings in children.values_mut() {
|
||||
siblings.sort_by_key(|id| names[id].clone());
|
||||
}
|
||||
let minima: BTreeMap<_, _> = baseline.nodes.iter().map(|n| (n.id, n.size)).collect();
|
||||
let headers: BTreeMap<_, _> = baseline
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
(
|
||||
n.id,
|
||||
if original.projection.state_machine().is_some() {
|
||||
projection::state_header_height(&result, n.element)
|
||||
} else {
|
||||
64.0
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut positions: BTreeMap<_, _> = baseline.nodes.iter().map(|n| (n.id, n.position)).collect();
|
||||
let mut sizes = minima.clone();
|
||||
let mut work = 0;
|
||||
let mut group_order: Vec<_> = children.keys().copied().collect();
|
||||
group_order.sort_by_key(|parent| {
|
||||
parent.map_or(0, |id| {
|
||||
baseline.nodes.iter().find(|n| n.id == id).unwrap().depth + 1
|
||||
})
|
||||
});
|
||||
let mut groups = BTreeMap::new();
|
||||
for pass in 0..3 {
|
||||
for parent in &group_order {
|
||||
let siblings = &children[parent];
|
||||
let mut arcs = BTreeSet::new();
|
||||
for edge in &baseline.edges {
|
||||
spend(&mut work)?;
|
||||
let a = child_under(edge.source, *parent, &parents, &mut work)?;
|
||||
let b = child_under(edge.target, *parent, &parents, &mut work)?;
|
||||
if edge.oriented
|
||||
&& let (Some(a), Some(b)) = (a, b)
|
||||
&& a != b
|
||||
{
|
||||
arcs.insert((a, b));
|
||||
}
|
||||
}
|
||||
let ranks = ranks(siblings, &arcs, &mut work)?;
|
||||
let mut layers: BTreeMap<usize, Vec<Id>> = BTreeMap::new();
|
||||
for id in siblings {
|
||||
layers.entry(ranks[id]).or_default().push(*id);
|
||||
}
|
||||
// Boundary neighbours participate even though they are not siblings.
|
||||
// Incoming preference anchors the downstream order to its upstream
|
||||
// counterpart instead of swapping both sides simultaneously.
|
||||
let mut scores = BTreeMap::new();
|
||||
for id in siblings {
|
||||
let mut incoming = Vec::new();
|
||||
for edge in &baseline.edges {
|
||||
spend(&mut work)?;
|
||||
let a = child_under(edge.source, *parent, &parents, &mut work)?;
|
||||
let b = child_under(edge.target, *parent, &parents, &mut work)?;
|
||||
if edge.oriented && b == Some(*id) && a != Some(*id) {
|
||||
incoming.push(positions[&edge.source].y + sizes[&edge.source].height / 2.0);
|
||||
}
|
||||
}
|
||||
// Do not also chase outgoing boundary neighbours: simultaneous
|
||||
// swaps in two connected containers can preserve the crossing.
|
||||
let values = &incoming;
|
||||
let score = if values.is_empty() {
|
||||
positions[id].y
|
||||
} else {
|
||||
values.iter().sum::<f32>() / values.len() as f32
|
||||
};
|
||||
scores.insert(*id, score);
|
||||
}
|
||||
for layer in layers.values_mut() {
|
||||
// First pass starts from canonical alias order; subsequent passes
|
||||
// retain a deterministic alias tie break, never hash iteration.
|
||||
if pass > 0 {
|
||||
layer.sort_by(|a, b| {
|
||||
scores[a]
|
||||
.total_cmp(&scores[b])
|
||||
.then_with(|| names[a].cmp(&names[b]))
|
||||
});
|
||||
}
|
||||
}
|
||||
groups.insert(*parent, layers.into_values().collect::<Vec<_>>());
|
||||
}
|
||||
// Recompute from frozen minima, never add spacing to last pass's size.
|
||||
sizes = minima.clone();
|
||||
measure(None, &groups, &headers, &mut sizes, gap_x, gap_y, &mut work)?;
|
||||
place(
|
||||
None,
|
||||
Position { x: 40.0, y: 40.0 },
|
||||
&groups,
|
||||
&headers,
|
||||
&sizes,
|
||||
&mut positions,
|
||||
gap_x,
|
||||
gap_y,
|
||||
&mut work,
|
||||
)?;
|
||||
}
|
||||
let d = result.diagrams.get_mut(&diagram).unwrap();
|
||||
for (id, node) in &mut d.nodes {
|
||||
node.position = positions[id];
|
||||
node.size = sizes[id];
|
||||
}
|
||||
let deltas: BTreeMap<_, _> = original
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|(id, node)| {
|
||||
(
|
||||
*id,
|
||||
Position {
|
||||
x: positions[id].x - node.position.x,
|
||||
y: positions[id].y - node.position.y,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let translated: BTreeMap<_, _> = projection::translated_routes(project, original, &deltas)
|
||||
.into_iter()
|
||||
.map(|r| (r.edge, r))
|
||||
.collect();
|
||||
d.routes = original
|
||||
.routes
|
||||
.iter()
|
||||
.map(|route| {
|
||||
let mut route = translated.get(&route.edge).unwrap_or(route).clone();
|
||||
if eligible.contains(&route.edge) {
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
}
|
||||
route
|
||||
})
|
||||
.collect();
|
||||
if d.nodes.values().any(|n| {
|
||||
!n.position.x.is_finite()
|
||||
|| !n.position.y.is_finite()
|
||||
|| !n.size.width.is_finite()
|
||||
|| !n.size.height.is_finite()
|
||||
|| n.size.width <= 0.0
|
||||
|| n.size.height <= 0.0
|
||||
}) {
|
||||
return Err(ModelError("Invalid layered probe geometry.".into()));
|
||||
}
|
||||
oa_core::validate_candidate(&result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn child_under(
|
||||
mut id: Id,
|
||||
parent: Option<Id>,
|
||||
parents: &BTreeMap<Id, Option<Id>>,
|
||||
work: &mut usize,
|
||||
) -> Result<Option<Id>, ModelError> {
|
||||
for _ in 0..=parents.len() {
|
||||
spend(work)?;
|
||||
let Some(actual) = parents.get(&id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if *actual == parent {
|
||||
return Ok(Some(id));
|
||||
}
|
||||
let Some(next) = actual else { return Ok(None) };
|
||||
id = *next;
|
||||
}
|
||||
Err(ModelError("Layered probe containment cycle.".into()))
|
||||
}
|
||||
|
||||
fn ranks(
|
||||
ids: &[Id],
|
||||
arcs: &BTreeSet<(Id, Id)>,
|
||||
work: &mut usize,
|
||||
) -> Result<BTreeMap<Id, usize>, ModelError> {
|
||||
let mut remaining: BTreeSet<_> = ids.iter().copied().collect();
|
||||
let mut ranks: BTreeMap<_, usize> = BTreeMap::new();
|
||||
while !remaining.is_empty() {
|
||||
let mut ready = None;
|
||||
for id in ids.iter().filter(|id| remaining.contains(*id)) {
|
||||
let mut blocked = false;
|
||||
for (a, b) in arcs {
|
||||
spend(work)?;
|
||||
blocked |= b == id && remaining.contains(a);
|
||||
}
|
||||
if !blocked {
|
||||
ready = Some(*id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Explicit cycle fallback: break the first canonical remaining vertex's
|
||||
// incoming ranking constraints. Actual relation direction is unchanged.
|
||||
let id = ready.unwrap_or_else(|| *ids.iter().find(|id| remaining.contains(*id)).unwrap());
|
||||
let mut rank = 0;
|
||||
for (a, b) in arcs {
|
||||
spend(work)?;
|
||||
if *b == id
|
||||
&& let Some(previous) = ranks.get(a)
|
||||
{
|
||||
rank = rank.max(previous + 1);
|
||||
}
|
||||
}
|
||||
ranks.insert(id, rank);
|
||||
remaining.remove(&id);
|
||||
}
|
||||
Ok(ranks)
|
||||
}
|
||||
|
||||
type Groups = BTreeMap<Option<Id>, Vec<Vec<Id>>>;
|
||||
|
||||
fn measure(
|
||||
parent: Option<Id>,
|
||||
groups: &Groups,
|
||||
headers: &BTreeMap<Id, f32>,
|
||||
sizes: &mut BTreeMap<Id, Size>,
|
||||
gx: f32,
|
||||
gy: f32,
|
||||
work: &mut usize,
|
||||
) -> Result<Size, ModelError> {
|
||||
let Some(layers) = groups.get(&parent) else {
|
||||
return Ok(Size {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
});
|
||||
};
|
||||
let mut width = 0.0;
|
||||
let mut height = 0.0_f32;
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
let mut lw = 0.0_f32;
|
||||
let mut lh = 0.0;
|
||||
for (j, id) in layer.iter().enumerate() {
|
||||
spend(work)?;
|
||||
let content = measure(Some(*id), groups, headers, sizes, gx, gy, work)?;
|
||||
let size = sizes.get_mut(id).unwrap();
|
||||
if groups.contains_key(&Some(*id)) {
|
||||
size.width = size.width.max(content.width + 48.0);
|
||||
size.height = size.height.max(content.height + headers[id] + 24.0);
|
||||
}
|
||||
lw = lw.max(size.width);
|
||||
lh += size.height + if j == 0 { 0.0 } else { gy };
|
||||
}
|
||||
width += lw + if i == 0 { 0.0 } else { gx };
|
||||
height = height.max(lh);
|
||||
}
|
||||
Ok(Size { width, height })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn place(
|
||||
parent: Option<Id>,
|
||||
origin: Position,
|
||||
groups: &Groups,
|
||||
headers: &BTreeMap<Id, f32>,
|
||||
sizes: &BTreeMap<Id, Size>,
|
||||
positions: &mut BTreeMap<Id, Position>,
|
||||
gx: f32,
|
||||
gy: f32,
|
||||
work: &mut usize,
|
||||
) -> Result<(), ModelError> {
|
||||
let Some(layers) = groups.get(&parent) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut x = origin.x;
|
||||
for layer in layers {
|
||||
let mut y = origin.y;
|
||||
let mut width = 0.0_f32;
|
||||
for id in layer {
|
||||
spend(work)?;
|
||||
positions.insert(*id, Position { x, y });
|
||||
place(
|
||||
Some(*id),
|
||||
Position {
|
||||
x: x + 24.0,
|
||||
y: y + headers[id],
|
||||
},
|
||||
groups,
|
||||
headers,
|
||||
sizes,
|
||||
positions,
|
||||
gx,
|
||||
gy,
|
||||
work,
|
||||
)?;
|
||||
y += sizes[id].height + gy;
|
||||
width = width.max(sizes[id].width);
|
||||
}
|
||||
x += width + gx;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
# Automatic layout investigation
|
||||
|
||||
Issue [32](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/32)
|
||||
owns scope, ownership, milestones and integration status. This is an experiment
|
||||
record above released `715036e132677dd2bc2fb37ed316d93dc9987aec`, not an accepted
|
||||
replacement layout contract or a completed fix. User-owned requirements remain
|
||||
in `PROJECT_GOAL.md` and the issue.
|
||||
|
||||
## Question and existing evidence
|
||||
|
||||
Can explicit Auto-layout produce a readable arrangement that accounts for
|
||||
connections and their labels, including removal of stale bends, while preserving
|
||||
semantic endpoints, containment/allocation, source parity and reversible editing?
|
||||
|
||||
The current `session::layout` sizes the containment forest, sorts roots using
|
||||
directed edges and alias tie breaks, places four roots per row, and translates
|
||||
authored route points only when both actual endpoints move equally. Its authored
|
||||
route preservation is deliberate and tested in `tests/release_routing.rs` and
|
||||
`tests/state_diagrams.rs`; the user's requested automatic bend replacement needs
|
||||
an explicit amendment, not a silently weakened test.
|
||||
|
||||
The [source map](experiments/auto-layout-20260925/source-evaluator-map.md)
|
||||
identifies the actual callers and preserved input/source/API contracts. The
|
||||
[primary-source research](experiments/auto-layout-20260925/research.md) compares
|
||||
Sirius/Capella workflows, ELK and reuse of the existing Rust router. The
|
||||
[candidate critique](experiments/auto-layout-20260925/candidate-critique.md)
|
||||
records unresolved design gaps before any production choice.
|
||||
|
||||
The existing `routing` module already supplies bounded deterministic orthogonal
|
||||
search, node/header obstacles, fixed semantic port endpoints, crossing/shared-run
|
||||
costs and automatic label collision search. Automatic elbows are derived, not
|
||||
persisted. The investigation should reuse this capability unless experiments
|
||||
show a concrete reason to replace it. Process topology rendering is a different
|
||||
path; its reported overlapping routes do not alone prove an Auto-layout defect.
|
||||
|
||||
## Candidate decisions, not yet accepted
|
||||
|
||||
Compare these materially different choices against visible route/label quality,
|
||||
semantic conservation, predictable user control, runtime/dependency cost,
|
||||
determinism, model size and maintainability:
|
||||
|
||||
1. Retain node arrangement and only reset effective visible route/label constraints.
|
||||
This is a useful control, but cannot by itself optimize poor node ordering.
|
||||
2. Use a hierarchy-aware layered node arrangement and the existing router, with
|
||||
label-aware spacing and bounded crossing-reduction sweeps. Explicit Auto-layout
|
||||
can replace effective visible pins/label offsets; ordinary drag keeps its
|
||||
existing preservation rules. This is a candidate, not a chosen implementation.
|
||||
3. Adopt a mature external layout engine, accounting for ports, labels, nested
|
||||
containers, runtime packaging and coordinate conversion. Evidence must justify
|
||||
the integration/dependency cost over reusing the current router.
|
||||
|
||||
Open contract questions include hidden/folded routes, authored attachment and port
|
||||
anchors, undirected and cyclic graphs, nested cross-boundary edges, repeated
|
||||
layout stability, and the observable treatment of exhausted search/poor results.
|
||||
No option may change semantic endpoints, ownership, allocation, expressions,
|
||||
relation kind or source identity to improve a drawing. Other views must remain
|
||||
unchanged, and one Undo on clean input must recover the complete pre-layout
|
||||
project. Existing application behavior applies an unrelated pending property
|
||||
draft first; that separate Apply is not silently folded into Layout's history.
|
||||
|
||||
The initial experiment retains the current command as the baseline and compares
|
||||
reset-only with a layered prototype isolated under `examples/auto_layout_probe`.
|
||||
Neither prototype changes production behavior. Effective reset eligibility must
|
||||
come from the pre-layout projection and actual endpoint identities; merely being
|
||||
drawn does not make a promoted route's dormant metadata eligible. Explicit label
|
||||
reset means both fraction and offset, while semantic text and endpoint/port
|
||||
attachments remain fixed. Production adoption remains conditional on the
|
||||
critic's hierarchy, stability, direction, measurement and global-work checks.
|
||||
|
||||
The first [probe recipe](experiments/auto-layout-20260925/probe-notes.md) covers
|
||||
nine small SA FunctionalDataflow fixtures. Its [independent source review](
|
||||
experiments/auto-layout-20260925/probe-review.md) found no blocker to that finite
|
||||
diagnostic run and lists important evaluator limitations. After that review,
|
||||
the parent inspected the added ten-call prototype stability loop and formatted
|
||||
the examples; the loop reports every differing/error result without a quality
|
||||
pass. No production file was changed. The experiment intentionally does not
|
||||
establish folded/filtered, State, Allocation, source-bundle or native acceptance.
|
||||
|
||||
## Evaluator before implementation
|
||||
|
||||
Use independently specified small graphs and real project views. Separate proper
|
||||
crossings, collinear shared runs, obstacle collisions, label/card collisions,
|
||||
label/label collisions, routing warnings, bends, length and occupied area. A low
|
||||
crossing count alone can hide coincident edges or enormous drawings. Exempt only
|
||||
explicitly documented intentional containment and common endpoint geometry;
|
||||
never discard a failure because it is inconvenient to the proposed algorithm.
|
||||
|
||||
Compare the current command and the reset-only control before choosing a design.
|
||||
Include a chain longer than the current four-column row, diamond/bipartite cases,
|
||||
nested cross-boundary exchanges, stale authored detours/labels, fixed ports,
|
||||
cycles/self-loops and disconnected components. Check deterministic repeat calls,
|
||||
idempotence, model/source round trips and complete Undo/Redo. A probe is evidence
|
||||
about its named fixtures, not universal graph quality or native usability.
|
||||
|
||||
Actual representative Windows workflows, including meaningful window/zoom sizes,
|
||||
selection and saved reopen, follow an implemented candidate. Linux remains
|
||||
excluded by the current user instruction. The separately published State label
|
||||
candidate in issue36 is not silently treated as integrated into this base.
|
||||
@@ -0,0 +1,38 @@
|
||||
# #32 candidate critique — 2026-09-25
|
||||
|
||||
Read-only adversarial review of `harness/auto-layout-investigation-20260925.md` in the715036e-based isolated checkout, against the source/evaluator map. This critiques candidate choices; no implementation, execution or acceptance is claimed.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Use the existing explicit **Command::Layout / Auto-layout / Arrange elements** operation for the new automatic behavior. The user specifically asks that operation to replace bends and consider labels/intersections. A second command/UI choice would preserve the obsolete behavior as a competing default without demonstrated need. Deliberately amend its documented contract and focused preservation tests, retain exact Undo, and retain existing ordinary drag/Arrange-next-drag contracts. A new command becomes warranted only if concrete compatibility/user workflows require both automatic policies, not merely because a test currently asserts old behavior.
|
||||
|
||||
Prefer comparing candidate1 reset-only against candidate2 layered siblings plus the existing router. No external engine is justified yet. The existing router already creates derived elbows, so reset does not require writing every solver corner as a persisted hard pin. Keep generated corners derived initially; the saved node placement plus remaining explicit attachments determines their repeatable reconstruction.
|
||||
|
||||
Recommended explicit Layout route contract: identify eligible visible, non-promoted edges from the **pre-layout projection**, remove their stored waypoints and label override (fraction and offset together), retain source/target attachments and node port anchors. Retain unrelated/hidden/promoted route records with existing equal-delta endpoint-owner translation. Do not call broad Reset routing if that also clears attachments. Preserve stable route-list order and remove an emptied record only under an explicit canonical no-metadata rule. Layout-generated record churn is not progress.
|
||||
|
||||
## Blocking design gaps before accepting candidate2
|
||||
|
||||
1. **Eligibility must be frozen once.** Visible edges, semantic endpoint-owner placements and promoted endpoints are different sets. Compute the reset set before node movement using projection visibility plus actual owner identity, not “every stored edge” or endpoints merely present in `d.nodes`. A filtered/hidden edge can retain a valid route; a folded edge may be drawn from an ancestor while its own route is suspended. Recomputing eligibility mid-layout risks erasing metadata when visibility/promotion changes. Small experiment: parallel visible/filtered edges, one folded child endpoint, and one both-endpoints-fold-to-same-anchor edge, all with distinctive pins/labels/attachments. Require only declared reset members to change, and retained pins to translate only through the existing owner-delta rule. Unfold/unfilter must reveal the retained custom route.
|
||||
|
||||
2. **Two forests are needed conceptually, not two model hierarchies.** The authored projection-parent forest includes folded descendants; the rendered topology may omit them. Reordering only rendered nodes can leave hidden children outside resized parents or leave future expand geometry broken. Conversely, expanding all hidden content for outer layout wastes space and ignores the actual view. Decide visible sibling arrangement versus hidden subtree placement/reservation explicitly. Lift each rendered inter-subtree edge to the relevant direct children at its lowest common ancestor; do not flatten every edge to roots (loses nested ordering) or count the same edge at every depth as independent demand. Small experiment: nested A/B children with crossed exchanges, folded A, external sibling C and a State entry/exit boundary. Check containment/header/port-footer rules before and after expand, with semantic parents/allocations exact.
|
||||
|
||||
3. **Idempotence will fail if spacing compounds current sizes.** Current measure uses each authored `node.size` as a lower bound. If label clearance expands a container and the next call adds the same margin again, repeated Arrange grows indefinitely. Label spacing based on previous routed bounds can similarly oscillate. Decide whether current sizes are immutable minima, or may shrink; the latter exceeds today's behavior and needs justification. Compute desired padding from content minima with `max`, not repeated addition to an already padded size. Small experiment: ten consecutive calls on nested long-label/parallel-edge fixtures, comparing full Projects after first vs second and sizes after tenth; repeat after Save/reparse. A two-call-only test can miss alternating period-two layouts.
|
||||
|
||||
4. **Cycle/direction policy is not settled by “stable sweeps”.** Some projected edges are undirected; direction may be semantic or merely an endpoint representation. Use directed topology where justified, deterministic SCC treatment for cycles, and explicit undirected ordering inputs. Do not arbitrarily make a Capability/Traceability edge's serialized orientation a mandatory rank. Freeze alias/identity tie rules; crossing sweeps require a stable acceptance tie-break to avoid ping-pong. Small experiment: directed diamond+return, reversed undirected endpoint records, self-loop, parallel/reversed edges and disconnected isolates. Permute input records; independently require identical output for identical IDs and stable quality under UUID changes (not necessarily identical coordinates under reidentification).
|
||||
|
||||
5. **Label reset and label-aware spacing need different evidence.** Clearing authored overrides is a reasonable explicit-auto interpretation but must be announced, since issue32 says “take into account” labels, not explicitly erase them. Retain semantic text/typed references; only the target view's override changes. A stale far-away label is a concrete reset fixture; an intentionally constrained attachment/label case tests whether residual collisions are honestly reported. Base715 label widths are scalar estimates, and State title/multiline geometry is known inaccurate without36. Do not tune spacing to claim accurate measured-State acceptance on715. First isolate ordinary/association labels; use conservative declared bounds for experiments, then rerun State B/W/U once36 integrates. Font-free core/API parity forbids quietly using desktop-only font results inside Command::Layout.
|
||||
|
||||
6. **Bounded sweeps do not alone bound runtime.** Each complete route evaluation invokes previous-edge interactions, node obstacles and label comparisons; repeated per-sibling trial routing can multiply this cost by nodes×sweeps. Lifting topology naively can multiply edges×depth. Define global deterministic work budgets for candidate ordering/scoring, not just a fixed iteration count, and retain the best valid candidate on exhaustion. Route only a bounded number of whole candidates; cheap crossing surrogates can rank candidates but final acceptance must inspect actual geometry. Small experiment: a fixed family increasing sibling count/edge density/nesting, recording candidate count, comparison/work counters and wall time; include no-edge/isolate cases so they do not pay quadratic routing costs. No hard latency threshold should be fabricated from one machine.
|
||||
|
||||
## Input/source/API contract checks
|
||||
|
||||
- Clean whole-view Layout should create one reversible model/source transaction. `app.rs` applies a pending property draft before executing Layout, potentially as a separate transaction; do not accidentally promise one Undo reverses both an unrelated draft Apply and Layout. Use a clean-input one-step Undo oracle and a separate valid/invalid pending-draft admission test. Keep diagram UUID remapping, stale selection handling and disabled/pending form policy.
|
||||
- `prepare_command`/Document/source bundle/REST dry-run must produce the same Project for the same inputs without GUI fonts, caches or selection. A frontend-only rearrangement would fail this. Roundtrip the stored record representation, check unrelated source files/comments remain exact, and recover the exact old source/model with Undo—not merely visually similar positions.
|
||||
- Keep Allocation Arrange-next-drag, ordinary group translation, semantic ports, reconnect, Hide/show, collapse flags and other diagrams unchanged. A route reset belongs only to explicit Layout. A failed candidate must not clear history or leave a partially reset diagram.
|
||||
- Native validation later needs visible controls, Fit/export agreement, actual label containment and route readability, plus Save/reopen/Undo. Do not extend the implementation merely to expose a new State Auto-layout control before a demonstrated workflow need; shared State initialization still needs regression coverage.
|
||||
|
||||
## Smallest discriminating experiment set
|
||||
|
||||
Freeze six hand-built cases before implementation: (A) stale dogleg+far label with fixed attachments, (B) two-container reversed sibling connections with a known zero-crossing witness, (C) nested folded/filtered route-conservation sentinel, (D) cycle/self-loop/parallel/undirected variants, (E) long ordinary/association labels with feasible compact clearance, (F) dense bounded stress. Compare existing Layout, reset-only, and a layered prototype on the same independent vector: invalid overlap/penetration, label collisions, proper crossings, positive collinear overlap, turns, path length/stretch, area and routing warnings. Include ten-call idempotence, exact other-view/semantic conservation, source roundtrip and Undo/Redo.
|
||||
|
||||
Candidate2 earns selection only if it materially improves at least B/E over reset-only without regressing the hard invariants or bounded workload. A tie favors the smaller candidate. A nonplanar or contradictory-fixed-port fixture may retain crossings; zero-crossing-by-overlap, giant detours, hidden edges or clipped labels is not a solution. Failure on a named fixture should revise the exact layer/policy implicated, not trigger an automatic wholesale engine dependency.
|
||||
@@ -0,0 +1,47 @@
|
||||
# #32 bounded automatic-layout probe
|
||||
|
||||
Prepared `crates/oa-core/examples/auto_layout_probe.rs` in `.local/auto-layout-20260925` against reference source `715036e132677dd2bc2fb37ed316d93dc9987aec`. No production edits, compiler, Cargo, GUI or Gitea operation by this evaluator. Rustfmt parsed/formatted only the owned main example (`skip_children=true`). The separately owned `auto_layout_probe/layered.rs` is imported through its agreed `candidate(&Project, Id) -> Result<Project, ModelError>` interface; the evaluator does not use candidate internals to define fixtures, geometry metrics or expected conservation.
|
||||
|
||||
## Execution and output
|
||||
|
||||
From the worktree, in the parent's configured exclusive Windows toolchain environment:
|
||||
|
||||
```powershell
|
||||
cargo run --locked --offline -p oa-core --example auto_layout_probe > auto-layout-probe.jsonl
|
||||
```
|
||||
|
||||
The parent has `.local/run-auto-layout-check-20260925.py` for pinned receipt-based execution. Prefer that for recorded evidence; its source hash/commit must identify the actual build. The example's `reference_source` metadata is the investigation baseline, not a runtime attestation of checkout identity. Stdout is JSON Lines: one metadata/self-check record and nine fixture records. No output files, network calls or UI operations are performed by the example. Errors/unsupported candidates are explicit fields, not quality passes. There is no aggregate score or threshold; a zero metric is not a readability or hard-safety certification.
|
||||
|
||||
Each fixture emits the original and current `Command::Layout` Projects and real projected/routed geometry. It also emits the same observations for a **reset-only control applied after existing Layout**, and for the separately implemented experimental candidate invoked on the original Project. The control clears only waypoints and label placement on visible routed edges whose geometry is not suspended/promoted, retaining endpoint attachments, hidden/promoted records and empty route records. The candidate does not determine this control. Ten successive existing Layout calls and ten successive candidate calls are each compared with their preceding and first result, stopping with an explicit error if needed. Candidate identical-input repeat and one further application are also compared separately. Reset eligibility is measured after baseline Layout; the candidate freezes it before Layout. These all-visible fixtures do not change visibility, so broader eligibility-equivalence claims are deferred.
|
||||
|
||||
## Declared fixtures
|
||||
|
||||
1. Four-node flat chain, initially left to right.
|
||||
2. Seven-node chain, initially left to right, exposing four-column row wrapping if present.
|
||||
3. Diamond with branch/join semantics and declared two-level positions.
|
||||
4. Crossed bipartite ordering, including both straight and crossed pairings.
|
||||
5. Parent Function with two child Functions, internal exchange and exchange to an outside Function.
|
||||
6. Two-node authored high detour with a far label offset; reset-only isolates stale constraint cost.
|
||||
7. Two-node exchange with explicit source-Left0.3 and target-Right0.7 port anchors.
|
||||
8. Two parent Functions, each with two children, and reversed inter-parent pairings. A separate predeclared witness swaps only right-child y positions, aligning each source with its target. Its real routing is measured; no zero-crossing result is assumed.
|
||||
9. Three Functions with long ordinary exchange names, including a bypass; the actual existing summary/name excerpt bounds are observed.
|
||||
|
||||
Project, diagram, Function, placement, relation and automatically created port UUIDs are remapped to stable IDs in creation order. Port aliases are reset explicitly. Names and other aliases are unique per fixture, avoiding random-ID tie dependence. The fixture Projects in output allow independent repetition and offline visualization; projected node rectangles/ports and per-edge routed point sequences/label rectangles are also included.
|
||||
|
||||
## Metric definitions and exclusions
|
||||
|
||||
- **Proper crossings:** unique intersection locations per distinct edge pair, interior to both straight segments. Locations deduplicate at0.0001 world units. Segment endpoints, bend contacts, T junctions and collinear overlaps are excluded from this count. Raw crossings remain the primary count. A separate nonterminal count subtracts only contacts within `PORT_STUB` of a physically equal endpoint with the same projected owner, where both tested segments are terminal. Entire compacted first/last segments are never exempt.
|
||||
- **Shared runs:** summed pairwise collinear overlap length, reported separately from crossings. Raw length is primary. Common exact endpoint terminal overlap gets a separately reported exemption bounded by `PORT_STUB` per common endpoint, capped by the overlap. Three overlapping edges contribute three pairwise overlaps; retraced segments may contribute multiplicity. This is not union length.
|
||||
- **Unrelated node collisions:** positive rectangle intersection area for distinct nodes; ancestor/descendant pairs are exempt from this count. No inferred intent is attached to a reported collision.
|
||||
- **Label/node collisions:** positive intersection of real routed label bounds with projected node rectangles, including endpoint nodes. A label wholly inside an endpoint ancestor's *body below the header* is exempt; overlap with its header remains reported. Ordinary header height is64; dedicated State height uses the core helper. Border crossings are retained. These conservative boxes are not glyph measurements.
|
||||
- **Edge/node penetration:** per edge/node pair, positive clipped segment length in unrelated node interiors. Endpoint-owner nodes are exempt; endpoint ancestors contribute only their header obstacles, so intentional passage through container bodies/boundaries is not counted. This does not detect re-entry into an edge's own endpoint node.
|
||||
- **Label/stroke intersection:** positive clipped length of another edge's stroke inside a label rectangle, one record per label-edge/stroke-edge pair. Own-edge strokes are excluded; an intentional anchor/label association is not declared a defect. Stroke thickness, arrowheads and antialiasing are unmodeled.
|
||||
- **Other observations:** actual routing warnings and details; routed total length; direction-changing bends including reversal; total drawing bounding-box area (including labels/nodes); real node/port/edge geometry. Area is scale-sensitive and is not minimized at the expense of crossings or readability.
|
||||
|
||||
Tiny analytic assertions check proper crossing versus endpoint contact, collinear overlap versus separated lines, rectangle contact, and line/interior clipping. They will run with the example, but have not been executed by this author. Geometry tolerance is0.0001 world units, while proper-intersection parameter/parallel checks use1e-9.
|
||||
|
||||
## Conservation and limits
|
||||
|
||||
The current command is exercised through `Session`, with exact one-step Undo/Redo and history/no-op checks. Full JSON decode and formatted single-source parse round trips are compared to the complete Project before/after; these do not prove original multi-file comments/byte preservation. Semantic records, target-view nongeometry fields, other views, per-node identity/visibility/port anchors and authored routes are reported separately. Reset/candidate route equality may intentionally be false and must be assessed against their stated reset policy, not silently treated as semantic loss.
|
||||
|
||||
Folded/filter/hidden-route preservation is implemented in the reset eligibility check but not independently falsified by these nine fixtures. Dedicated State #36 native typography, dense stress, cycles, disconnected large sets, non-planar crossing lower bounds, self-crossings, self-loops, full stroke geometry and interactive cognitive load are absent. The candidate may explicitly reject unsupported cases. No algorithm can promise crossing-free output for arbitrary non-planar graphs; the paired-container witness only establishes a concrete alternative ordering to examine. Use vector observations and the output geometry to diagnose mechanisms before selecting a candidate; this is a bounded experiment, not a production layout-quality framework or native acceptance.
|
||||
@@ -0,0 +1,27 @@
|
||||
# #32 first diagnostic probe review
|
||||
|
||||
**No blocking finding for compiling/running the current finite experiment.** This is a source/evaluator-validity review, not production acceptance or a claim the candidate improves layout. Nine small deterministic FunctionalDataflow fixtures support a bounded comparison; later scope gaps should not delay this run. No compiler, GUI, helper, source edit or Gitea operation performed.
|
||||
|
||||
Reviewed snapshot SHA256: auto_layout_probe.rs `72d850fdd9aad65788004949380c28cba48a0c2be3dc28f3aef3900c7f8724c7`; auto_layout_probe/layered.rs `bda9a59968b4b0e284e8e1393eb86c90cdf0d856d5f9830ae3c6fc0043df839a`. The evaluator worker is separately finalizing candidate repetition reporting; later edits require a scoped diff check, not relabeling this reviewed snapshot.
|
||||
|
||||
## Evaluator validity
|
||||
|
||||
Raw strict-interior segment crossings and pairwise collinear shared length are primary outputs, with crossing coordinates deduplicated per edge pair. Endpoint sharing is not exempted wholesale: secondary figures subtract only bounded common physical tip lengths. Node/header, label/node, label/label and unrelated stroke/label measurements remain separate. Analytic primitive checks cover a proper crossing, endpoint touch, collinear overlap, disjoint parallel segments, rectangle touch and inside-length boundary. Complete before/after models and routed points are emitted, permitting independent examination beyond scalar totals. No quality threshold or automatic overall-pass field promotes a poor drawing to acceptance.
|
||||
|
||||
Fixture IDs are normalized before use; semantic port IDs/aliases are also stabilized. The manual reversed-child witness is separately authored and measured, not generated from candidate output. It must still be read as a witness with its reported validation/geometry, not an asserted universal optimum. Standard parse/JSON roundtrip and old-Layout history checks are meaningful within their named scope.
|
||||
|
||||
## Actionable limitations to retain when interpreting this run
|
||||
|
||||
1. **Reset control eligibility:** reset_effective_routes receives the already arranged Project, whereas layered candidate freezes its eligible set before Layout. For these all-visible, uncollapsed fixtures, Layout does not change membership/promotion, so this does not invalidate the current comparison. Freeze the original eligible IDs for future folded/filtered cases; do not generalize current equivalence.
|
||||
2. **Conservation granularity:** semantic/other-view/node-metadata checks are independent and useful. target_view_nongeometry_exact restores all routes and layout before comparing; authored_routes_exact will intentionally become false. Those fields do not separately prove attachment preservation, invisible-route retention or a no-change layout field. Add targeted comparisons before making those claims on future fixtures. The current prototype source copies original route records and only clears eligible waypoints/labels, then translates retained routes, so no observed source-level violation was found here.
|
||||
3. **History scope:** undo_exact/one_step_undo/redo_exact exercise existing Command::Layout through Session only. The layered candidate is a pure Project transformer, not an integrated command and has no tested operation/history lifecycle. Its repeat-same-input and second-call comparison are useful; ten-call candidate stability was pending in this snapshot. Do not describe baseline ten_layout_calls as candidate stability.
|
||||
4. **Metric coverage:** strict proper crossings deliberately exclude T contacts and intersections at segment bends; pairwise shared length counts segment-pair traversal multiplicity, not geometric union length. No candidate should be selected solely by one scalar if it shifts crossings into uncounted touches. Endpoint-owner interiors are excluded from unrelated-edge penetration, and label/stroke excludes its own edge. These are declared definitions, not proof of complete route validity. Association-end labels, actual glyph ink, State multiline measured bounds and port labels are not covered by current primary label metrics. Current nine fixtures contain functional exchanges, so these omissions do not corrupt their narrow comparison.
|
||||
5. **Fixture coverage:** all current fixture families are small SA FunctionalDataflow cases. Other-view exactness is vacuous where only one view exists; folding, filtering, omitted-node, State, Allocation, Scenario, undirected cycles, dense scaling, UI admission and multi-file source conservation are not established. Preserve these gaps explicitly rather than broadening the initial run unnecessarily.
|
||||
|
||||
## Prototype source risks the experiment should expose
|
||||
|
||||
The candidate preserves semantic records and route-list order, retains attachments/port anchors, rejects folded/omitted topology explicitly, and uses stable alias/ID order, deterministic cycle fallback, a graph limit and counted ordering work. Derived corners are not persisted as hard pins. No product file is replaced.
|
||||
|
||||
Three passes use frozen per-call minima, but those minima come from baseline Layout using the input's existing sizes; cross-call size ratcheting remains possible and should appear in repeated full-Project comparison. Global maximum label width controls every sibling gap, so one long label may balloon unrelated layout area. Incoming-neighbor ordering can improve the reversed-container fixture without proving general crossing reduction; undirected edges do not constrain ranks/order. The work budget excludes existing projection/routing work and several collections, so it bounds selected prototype loops, not total CPU complexity. With current small fixtures these are diagnostic candidate weaknesses, not reasons to prevent the run.
|
||||
|
||||
Interpret a failed invariant, growing repeated layout, worse collisions or huge area as evidence against this prototype. Preserve errors and unsupported statuses; do not use successful process exit as layout-quality acceptance. Decide further work from the produced geometry and independent metric vector. Production integration, complete command/source/API/history behavior, native readability and broader issue32 acceptance remain separate.
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Render recorded probe geometry for inspection; this is not a desktop screenshot.
|
||||
|
||||
Usage: python render_probe.py PROBE_STDOUT_JSONL NEW_OUTPUT_DIRECTORY
|
||||
Uses one scale across every result for each fixture; input geometry is unchanged.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def geometries(value, name=""):
|
||||
if not isinstance(value, dict):
|
||||
return
|
||||
if "routed_edges" in value and "projected_nodes" in value:
|
||||
yield name, value
|
||||
return
|
||||
for key, child in value.items():
|
||||
yield from geometries(child, f"{name}.{key}".strip("."))
|
||||
|
||||
|
||||
source, output = map(Path, sys.argv[1:])
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
font = ImageFont.truetype("C:/Windows/Fonts/arial.ttf", 13)
|
||||
files = []
|
||||
for line in source.read_text(encoding="utf-8").splitlines():
|
||||
item = json.loads(line)
|
||||
fixture = item.get("fixture")
|
||||
if not fixture:
|
||||
continue
|
||||
variants = list(geometries(item))
|
||||
if not variants:
|
||||
continue
|
||||
largest_width = max(g["drawing_bounds"]["size"]["width"] for _, g in variants)
|
||||
largest_height = max(g["drawing_bounds"]["size"]["height"] for _, g in variants)
|
||||
scale = min(1.0, 1140 / max(1, largest_width), 690 / max(1, largest_height))
|
||||
for name, geometry in variants:
|
||||
canvas = Image.new("RGB", (1200, 800), "white")
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
origin = geometry["drawing_bounds"]["position"]
|
||||
|
||||
def point(position):
|
||||
return (30 + (position["x"] - origin["x"]) * scale,
|
||||
70 + (position["y"] - origin["y"]) * scale)
|
||||
|
||||
def rectangle(position, size):
|
||||
x, y = point(position)
|
||||
return x, y, x + size["width"] * scale, y + size["height"] * scale
|
||||
|
||||
draw.text((20, 12), f"{fixture} / {name} | geometry only, not native UI", font=font, fill="black")
|
||||
draw.text((20, 32), f"Fixed fixture scale {scale:.4f}; text below identifies bounds, not glyph measurements", font=font, fill="black")
|
||||
for node in sorted(geometry["projected_nodes"], key=lambda n: (n["depth"], n["id"])):
|
||||
box = rectangle(node["position"], node["size"])
|
||||
draw.rectangle(box, fill="#eef2f6", outline="#687787", width=1)
|
||||
draw.text((box[0] + 3, box[1] + 3), node["id"][-5:], font=font, fill="#334155")
|
||||
for edge in geometry["routed_edges"]:
|
||||
points = [point(p) for p in edge["points"]]
|
||||
if len(points) > 1:
|
||||
draw.line(points, fill="#245c88", width=2)
|
||||
label = edge["label"]
|
||||
if label["text"]:
|
||||
box = rectangle(label["position"], label["size"])
|
||||
draw.rectangle(box, outline="#ad5715", width=1)
|
||||
target = output / f"{fixture}-{name.replace('.', '-')}.png"
|
||||
canvas.save(target)
|
||||
files.append({"name": target.name, "bytes": target.stat().st_size,
|
||||
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(), "scale": scale})
|
||||
(output / "manifest.json").write_text(json.dumps({
|
||||
"input_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"renderer_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
"scope": "Offline schematic of recorded core geometry; no native or font-rendering acceptance.",
|
||||
"images": files}, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Rendered {len(files)} geometry records without modifying the probe output.")
|
||||
@@ -0,0 +1,66 @@
|
||||
# #32 automatic arrangement: bounded research
|
||||
|
||||
Prepared 2026-09-25; local research, not an accepted design or implementation review. No compiler, GUI, helper, or remote mutation was run. Primary sources were read online. Local source spot-check: clean `.local/auto-layout-20260925`, commit `715036e132677dd2bc2fb37ed316d93dc9987aec`. Parent owns current implementation mapping and contract selection.
|
||||
|
||||
The user needs Arrange to generate/remove bends, reduce connection intersections, and account for connection labels, while retaining meaningful nested containment/deployment. A useful solution therefore needs coordinated node ordering, space allocation, routing, and label placement. Merely sorting boxes or clearing existing waypoints cannot establish success.
|
||||
|
||||
## Verified precedents and their limits
|
||||
|
||||
**Capella workflow.** Capella 7 release notes document Layout Children, temporary movement of pinned elements, and renaming Arrange All/Selection to Layout All/Selection. This supports making operation scope and preservation of manual organization explicit. It does **not** establish that Capella uses ELK as its default engine. [Official Capella 7 notes](https://github.com/eclipse-capella/capella/wiki/Release-Notes-7.0.0).
|
||||
|
||||
**Sirius workflow.** Its user guide describes avoiding overlap, fitting containers around contents, attempting fewer edge crossings, and preserving pinned locations. Layout Children operates at the immediate level. A selected container can have its contents arranged while its location remains fixed. The guide also describes simplifying an unpinned edge between pinned endpoints. However, its pinning section and later toolbar section disagree about edge pinning support; do not copy that ambiguity into ARCADIA's contract. [Sirius user guide](https://eclipse.dev/sirius/doc/user/diagrams/Diagrams.html). Sirius documents configurable default/composite and ordered-tree layouts, with ELK as an additional integration described as experimental. Availability of that integration is not evidence of a particular Capella installation's behavior. [Sirius layout configuration](https://eclipse.dev/sirius/doc/specifier/diagrams/Diagrams.html).
|
||||
|
||||
**ELK capabilities.** ELK Layered explicitly supports orthogonal routing, ports, labels, compound graphs, and cross-hierarchy edges with suitable configuration. [Algorithm reference](https://eclipse.dev/elk/reference/algorithms/org-eclipse-elk-layered.html). `INCLUDE_CHILDREN` allows a joint run through descendants; the root default otherwise separates children. Independent child layout is therefore not equivalent to globally considering connections across nested containers. [Hierarchy handling](https://eclipse.dev/elk/reference/options/org-eclipse-elk-hierarchyHandling.html). Port constraints distinguish free placement, fixed side/order/ratio, and fixed position. [Port constraints](https://eclipse.dev/elk/reference/options/org-eclipse-elk-portConstraints.html).
|
||||
|
||||
The authors describe a Sugiyama-family layered algorithm, constrained barycenter crossing minimization, size/port-aware coordinate assignment, and separate support for compound graphs and disconnected components. These are heuristics with configurable priorities, not a guarantee of globally minimal crossings. [ELK authors, GD 2024](https://drops.dagstuhl.de/storage/00lipics/lipics-vol320-gd2024/LIPIcs.GD.2024.56/LIPIcs.GD.2024.56.pdf).
|
||||
|
||||
**Routing is a distinct problem.** Wybrow, Marriott, and Stuckey describe orthogonal visibility graphs, A* route search, and ordering/nudging of shared segments. Their bend/length optimality statement concerns individual routes under their model, not joint optimal placement of all nodes, labels, and connectors. [Orthogonal Connector Routing, GD 2009](https://people.eng.unimelb.edu.au/pstuckey/papers/gd09.pdf). libavoid exposes penalties for crossings, segments, cluster crossings and shared paths, plus endpoint direction constraints. It supplies routing rather than a complete node arrangement solution. [libavoid API](https://www.adaptagrams.org/documentation/namespaceAvoid.html).
|
||||
|
||||
## Materially different approaches
|
||||
|
||||
| Candidate | Practical proposal for this project | Main advantage | Main risk / decisive falsifier |
|
||||
| --- | --- | --- | --- |
|
||||
| Native Rust layered arrangement + existing router | Build a bounded, deterministic placement pipeline over the projected hierarchy; reserve measured label space and improve routes after placement. | Best match to Rust preference, existing model/history, and deployment; reuse current geometry. | Nested cross-boundary routes and label congestion make a supposedly small implementation grow. Reject if the finite compound fixtures require ad hoc per-diagram exceptions or cannot reduce known avoidable crossings. |
|
||||
| ELK Layered through elkjs | Translate one immutable projected graph to ELK JSON, run a pinned bundled JS engine off the UI thread, validate results, apply a presentation transaction. | Mature joint treatment of ports, labels, layering and hierarchy. | Adds runtime/distribution/adapter cost. Reject if exact port semantics, nested coordinates, measured label boxes, bounded cancellation, or Windows/Linux packaging cannot be demonstrated. |
|
||||
| Preserve nodes; improve routes and label locations | Use existing Rust routing plus bounded rerouting/order search, or investigate libavoid through FFI. | Least disruption to the user's spatial organization; useful separate Reroute command. | Cannot untangle crossings imposed by node/port ordering. Insufficient as the sole Arrange implementation if an order swap solves a fixture it cannot improve. |
|
||||
|
||||
elkjs is the official JavaScript form of ELK's layout algorithms, consumes/returns graph geometry, and supports worker execution. Using the distributed JavaScript does not require a Java runtime. A Rust embedding still needs an explicitly selected JS runtime or packaged process; compatibility, footprint, cancellation and licensing must be checked in a bounded spike. It is not a Rust library or a ready Iced integration. [elkjs repository and API](https://github.com/kieler/elkjs).
|
||||
|
||||
Graphviz is a possible comparison baseline, not my preferred default: its current official `splines=ortho` documentation explicitly says this routing does not handle ports or, for dot, edge labels. That limitation directly intersects this task. A separate router/label solver could repair the integration, but removes much of its simplicity. [Graphviz splines](https://graphviz.org/docs/attrs/splines/). Force/stress layout is another possible overview style, but I would not spend the first experiment on it: engineering flow direction, nesting and endpoint constraints still need substantial machinery afterward. This is a fit judgment, not a claim those algorithms cannot be extended.
|
||||
|
||||
## Proposed minimum contract, independent of engine
|
||||
|
||||
1. **Operate on diagram presentation.** Preserve semantic identities, relation direction, containment/deployment, port identity and connectivity, node/edge membership, and label text. Layout cycle breaking must never reverse the actual model relation. Projected parentage, not an independently guessed semantic tree, defines nesting. Hidden/collapsed content must have a declared policy; do not silently expose it.
|
||||
2. **Define scope before implementation.** Initial whole-diagram Arrange is simpler than arbitrary selection. If selection is included, decide whether a container means its immediate children or its full subtree, whether it can grow, and how boundary-crossing edges update. Outside nodes remain fixed. Do not infer support for selection from a whole-graph solver.
|
||||
3. **Define replaceable geometry.** Explicit Arrange may replace the in-scope route waypoints, creating useful bends and removing redundant ones. Decide independently whether authored endpoint attachments and authored label placement are preserved or reset. Recommended initial rule: preserve semantic/fixed attachments; authorize replacement of bend geometry; treat preserved label boxes as constraints. Do not retain stale absolute label offsets accidentally after wholesale rearrangement. Parent must settle this user-visible policy.
|
||||
4. **Measure before arranging.** Supply visible node dimensions, headers, port/row areas, label rectangles and clearance in world units. Use the same prepared text and measurement as painting, including multiline labels and fallback glyphs. Feed label occupancy into spacing/routing, then validate the final rendered geometry. Fitting labels only after arranging boxes can recreate collisions.
|
||||
5. **Atomic application.** Return a candidate presentation delta, validate it, then create one Undo entry. Failure/cancellation leaves the model unchanged. Undo restores complete prior geometry; Redo and save/reopen reproduce the result. A stale asynchronous result must not overwrite edits made since its input snapshot.
|
||||
6. **Quality, not an impossible universal promise.** Require no invalid geometry, prohibited node/header penetration, or lost constraints. Minimize avoidable crossings, label collisions and shared-path ambiguity before compactness and bend count. Preserve a satisfactory baseline when candidates are worse. Hard constraints may make a zero-collision drawing impossible; report the residual rather than claiming success from a cost scalar.
|
||||
|
||||
The local spot-check explains one important seam: `crates/oa-core/src/session.rs::layout` already obtains `projection::parents`, recursively sizes nested nodes and rejects Scenario geometry. `routing.rs` explicitly distinguishes persisted authored pins from bounded derived routes. Generated bends need not all become persisted manual pins: persist the chosen presentation change according to a declared regeneration policy. The #36 successor's measured State labels must be reconciled when integrating; this base predates it. No claim about uninspected callers or final API follows from this spot-check.
|
||||
|
||||
## Native Rust candidate: finite algorithm experiment
|
||||
|
||||
This is a proposal, not an implementation prescription. Build a temporary layout graph with stable IDs, measured boxes, containment, real port constraints and typed edges. Determine feedback edges for cycles without modifying semantics; assign layers; insert temporary long-edge/label placeholders; perform a fixed number of alternating barycenter/median sweeps with adjacent swaps accepted only when crossing cost improves; assign non-overlapping coordinates. Lay out children with explicit boundary connection ordering and reserve parent headers/port areas. Bottom-up subtree packing alone is a baseline, not proof of cross-hierarchy quality.
|
||||
|
||||
Route orthogonally around the resulting obstacles, with penalties for bends, crossings and excessive shared paths. Place measured labels in reserved corridors; if conflicts remain, permit a bounded spacing/rerouting pass. Remove duplicate and collinear intermediate vertices. Keep separate metrics for edge crossings, shared-path length, label collisions, bends, area and displacement so a shorter route cannot disguise a newly unreadable diagram. Stable tie-breaking, bounded passes and retaining the best valid candidate limit oscillation and cost. Compare this candidate against the same finite fixtures processed by elkjs before investing in a general framework.
|
||||
|
||||
## Falsifiers and finite milestones
|
||||
|
||||
**First: freeze the evaluator and geometry policy.** Six small fixtures are enough to expose foundational errors: (a) a two-layer order swap with a known crossing-free solution; (b) a diamond plus cycle/self-loop; (c) a two-level OA containment graph with internal and cross-container connections; (d) SA deployment nesting with fixed semantic ports and high-degree fan-out; (e) long/multiline labels in a tight corridor; (f) disconnected components, collapsed content and an already tidy layout. Include one route needing added bends and one needing removed bends. If scope excludes any fixture feature, state that before scoring.
|
||||
|
||||
Count proper interior intersections between distinct routed edges separately from collinear overlaps. Exclude common endpoint contact, but do not exempt edges merely because they share a source: an intersection elsewhere still counts. Count label-label and label-unrelated-edge/node intersections; intentional own-edge label contact and container background containment need specific exemptions. Check allowed container boundary passages rather than counting every containment-border crossing as an error. Record scores on final routes, not only straight-line layer proxies.
|
||||
|
||||
**Second: choose using measured results.** For the known solvable fixtures, demand the known crossing/collision target, semantic equality, correct port attachment and legal containment. Test deterministic repetition, a second Arrange reaching a stable result, and small perturbations not causing unbounded growth. Include invalid inputs/work-budget exhaustion; preserve the input on failure. Set an interactive-time budget against a declared representative model size and hardware before timing, rather than inventing a universal threshold here.
|
||||
|
||||
**Third: verify the real workflow.** Parent performs Arrange, inspects routes and labels at normal reading zoom, Undo, Redo, save and fresh reopen on OA and SA models. Check that connections remain traceable and nesting still conveys the intended relationship; aggregate crossing counts alone do not establish readability. No such execution or acceptance is claimed by this research.
|
||||
|
||||
Recommended next decision: settle reset/preservation of bends, endpoint attachments and label anchors, then compare the native layered candidate and elkjs on the same compound, port-and-label fixtures. Route-only improvement is a useful fallback capability but should not silently redefine the user's Arrange request.
|
||||
|
||||
## Parent's current code findings: implication for the first experiment
|
||||
|
||||
Parent reports that the existing router already has deterministic bounded orthogonal search, obstacle exclusion, crossing/shared-run penalties and automatic label collision sampling; current Arrange uses a four-per-row root placement after topological/alias ordering. These are parent-provided inspection findings, not independent runtime verification here. They materially favor **reuse of existing routing plus improved node arrangement** as the first candidate, rather than reimplementing routing or immediately adopting another engine. Feed its final-route metrics back into a bounded choice among placement candidates; label sampling alone still cannot create missing corridor space.
|
||||
|
||||
Parent's proposed policy—preserve semantic ports, authored port anchors and endpoint attachment preferences; reset only visible effective bends/label overrides during explicit full Auto-layout; retain hidden/promoted metadata; leave automatic elbows derived—is a coherent candidate. Its key falsifier is visibility scope: a folded/proxy edge must not cause deletion of dormant metadata belonging to a hidden original endpoint/route. Define “visible effective” by exact original edge ID plus unsuspended geometry, not just membership in the rendered edge list. Test folded then expanded diagrams, ancestor-promoted endpoints, partially visible authored routes and a visible label whose anchor was inherited. If retained dormant metadata becomes effective on expansion, the restored manual shape is expected under this policy and should not be advertised as newly automatically arranged. Explicit full Auto-layout can reasonably reset visible label overrides because its stated purpose authorizes rearrangement, but ordinary redraw, drag and routing must not acquire that reset behavior.
|
||||
|
||||
Compare reuse against elkjs only after scoring this bounded candidate. A successful existing-router experiment removes much of the justification for another runtime; persistent compound/port-order failures would supply concrete reasons to reopen engine adoption. The newly observed untracked `harness/auto-layout-investigation-20260925.md` in the worktree is parent work; this research did not modify it or product source.
|
||||
@@ -0,0 +1,61 @@
|
||||
# #32 Auto-layout source and evaluator map
|
||||
|
||||
Read-only inspection of `.local/auto-layout-20260925`, base715036e132677dd2bc2fb37ed316d93dc9987aec, on2026-09-25. Issue32 explicitly asks automatic creation/removal of bends, attention to connection intersections and labels. No source changes, compiler, GUI, model helper or Gitea writes were performed. No geometric outcomes below are claimed as measured results.
|
||||
|
||||
## Actual surfaces and shared execution
|
||||
|
||||
- `crates/oa-desktop/src/views.rs:443`: saved Architecture diagram toolbar **Auto-layout** sends `Message::Command(Command::Layout(id))`. `model_menu.rs:417`: background **Arrange elements** sends the same command. The generic chooser excludes Scenario, not State, but normal State navigation routes to its dedicated workspace. `behavior/state_menu.rs` returns early for the dedicated State menu, and `state_view.rs` has no equivalent Auto-layout button. State view creation still calls shared Layout (`behavior/state_edit.rs:361–410`); behavior creation batches Layout too (`behavior/creation.rs:477–504`). Distinguish exposed UI from callable core behavior.
|
||||
- `crates/oa-core/src/model.rs:1431`: saved projection kinds are Overview, Decomposition, Allocation, Traceability, FunctionalDataflow, Capabilities, StateMachine and Scenario. `session.rs:947` accepts the first seven; Scenario explicitly refuses and requires its separate geometry operation. Process topology and Scenario lane/message arrangement are separate behavior canvases, not this generic command.
|
||||
- Allocation **Arrange next drag / Alt+A** is a different manual translation workflow (`allocation_drag/arrange.rs`, `app/allocation_drag.rs`, `diagram/work_drag.rs`). Do not silently turn it into automatic graph layout or change its allocation/group/refusal/Undo contracts merely because its label contains Arrange.
|
||||
- `app.rs:3559` applies pending property edits and remaps a changed diagram identity before executing Layout; preserve that admission/identity behavior (`tests.rs::placement_identity_edit_then_drag_and_diagram_identity_then_arrange`). `document.rs:322` prepares/validates a candidate, updates the source bundle with identity provenance, then commits one model/source history entry. Core Session execute at`session.rs:275` likewise records one changed candidate; Undo/Redo swap exact Projects and no-op candidates create no history.
|
||||
- REST transactions at`crates/oa-server/src/routes.rs:1011–1041` accept serialized Command batches including Layout under expected-revision admission, with dry-run and atomic PreparedProject/store commit. Core behavior therefore cannot rely on desktop-only fonts/state. `docs/v2-interface.md` lists Layout convenience commands. CLI exposes check/lint/stabilize/rename-alias, not a direct layout verb. Text/source may store explicit node/route records; parsing/loading them must not implicitly auto-arrange.
|
||||
|
||||
## Current algorithm and why the issue is real
|
||||
|
||||
`session.rs::layout` measures a projection-parent forest, vertically stacks children below headers, and puts roots into rows of four. Roots use an exchange-aware topological-like ordering with alias/ID tie breaks and deterministic cycle fallback. Child order is placement-ID order. It changes node positions/sizes, computes per-node deltas and calls `projection::translated_routes`; it neither scores routed geometry/labels nor generates or removes stored waypoints.
|
||||
|
||||
`projection.rs:81` supplies actual visual parents: nearest placed semantic ancestor; Allocation derives performer grouping from allocation summaries; Traceability is flat; State filters the machine. Ports have owner placements, not independent node placements (`edge_endpoint_placements`). Measurement reserves minimum sizes, State action/internal rows and port footer (`session.rs:967–1006`). Folded descendants still belong to the authored forest; projected visibility and stored layout are distinct.
|
||||
|
||||
The router is **not unaware of crossings or labels**: `routing.rs:457` routes stable edge IDs with a bounded search and previous-edge costs; `crossing_cost:990` penalizes proper crossings and positive collinear overlap separately. All paths settle before automatic label placement; `label_geometry`/`label_collisions:1370–1552` consider node obstacles, earlier labels and all visible strokes. But routing remains greedy, has finite search budgets, and authored label placement bypasses avoidance. Auto-layout never feeds these costs back into node order/spacing. Existing pins constrain paths even after node arrangement, except folded/promoted routes temporarily suspend their custom geometry. Thus a node-only Layout plus pinned greedy routing cannot promise the joint improvement requested by32.
|
||||
|
||||
Base715 uses scalar-estimated label rectangles. The separate #36 measured State multiline layout is not present in this base; do not assume its geometry or acceptance has already integrated. Ordinary labels, association-end labels/stereotypes and State labels also differ in presentation. Parent should coordinate routing-file overlap with #36 before integration, not silently reimplement its measurement policy.
|
||||
|
||||
## Contracts requiring an intentional change
|
||||
|
||||
1. `harness/release-routing-contract.md:143–146` explicitly says Layout preserves pins, translating only equally moved endpoint owners, with Reset routing separate. `crates/oa-desktop/src/guide.rs:360` repeats **Auto-layout preserves pins**. These are directly incompatible with the user's requested automatic bend removal/generation and must be revised for the explicit whole-view operation, with Undo restoring prior authorship.
|
||||
2. `crates/oa-core/tests/release_routing.rs::layout_preserves_authored_route_count_order_labels_and_undo` fixes route list count/order, two pins on each route and exact labels. Replace only the superseded Layout assertions with the new declared output contract plus stronger geometry checks; retain unrelated-diagram exactness and exact Undo. Do not remove all route-preservation tests.
|
||||
3. `state_diagrams.rs::layout_reserves_internal_rows_and_state_routes_retain_hard_pins` combines two things: the **pre-Layout** router must traverse authored pins without mutation; the post-Layout assertions reserve State rows and source roundtrip. Preserve those valid assertions, and rename/split the test if Layout no longer retains pins. Its current body does not actually assert post-Layout pin preservation.
|
||||
4. Decide explicitly whether generated bends remain derived route corners or become persisted editable waypoints. The user asks visible automatic routing improvement, not necessarily a new semantic field. `routing.rs` documents only authored pins in the model; blindly storing every solver corner would turn generated geometry into hard constraints and can cause second-run drift. Whichever representation is chosen needs first/repeat Arrange and manual-after-Arrange tests.
|
||||
5. Define scope for invisible/suspended routes, authored label offsets and explicit attachments. User32 authorizes changing bends; it does not automatically authorize deleting manual semantic-port metadata, every hidden route, or authored label placement. A narrow baseline is reset/rebuild bends for eligible visible edges while retaining attachments, labels and unrelated/hidden records; measure whether that suffices. If an authored label constraint defeats label clearance, report it or make its intentional reset policy explicit instead of pretending the layout is collision-free.
|
||||
|
||||
## Invariants to retain
|
||||
|
||||
Complete elements/relations/traces/types/names/text/IDs/containment/allocations/occurrences remain exact; only the selected diagram's declared presentation fields may differ. Other diagrams, node identities, collapsed flags, view options, unplaced elements and manual port side/fraction metadata remain exact. Automatic port geometry may change with node geometry, but semantic port identity/contract and explicit anchors do not. Existing tests `port_anchors::separate_views_resize_layout_and_hidden_owners_keep_manual_metadata`, `workflows` nested-parent containment, and desktop port/identity tests are relevant sentinels.
|
||||
|
||||
Keep move-one-end pins-fixed and move-both-owners equal-delta translation in `projection::translated_routes`, normal drag/reflow/group previews, reconnect, folding suspension/resumption, Hide/show and Reset routing unchanged unless separately justified. Keep finite geometry and existing point-count/attachment/fraction validation (`validation.rs:650–720`), deterministic bounded failure/fallback, source/JSON roundtrips, dry-run nonmutation, rejected-operation no-history, and exact one-step Undo/Redo. A Layout improvement must not trigger new allocation/reconnect semantics.
|
||||
|
||||
## Independent fixtures and falsifiers to freeze before candidate comparison
|
||||
|
||||
Use hand-declared topology, IDs and geometry; record the old candidate's metrics once, then compare materially different candidates against the same evaluator. Do not use a candidate's own cost function as the acceptance oracle.
|
||||
|
||||
| Fixture | Independent observable requirement |
|
||||
| --- | --- |
|
||||
| Two connected OA activities / SA functions with redundant dogleg and one blocking third card | Explicit Arrange removes obsolete turns; unobstructed edge straightens, blocked edge gains necessary orthogonal turns; semantic endpoints exact, no nonincident-node penetration. Include authored source/target attachments. |
|
||||
| Two containers, each with two children, reversed child-order connections | A planar hand-constructed witness has zero proper crossings; require zero on this bounded case, child containment and preserved port contract. Detect current ID-ordered child stacking weakness. |
|
||||
| Four-node diamond plus return edge, self-loop, parallel and reverse-direction edges | No false cycle/deadlock; meaningful separation of parallel paths; self-loop remains visible; distinguish shared endpoint fans from mid-path overlap. Test swapped UUIDs and stable semantic aliases. |
|
||||
| Five-plus roots and disconnected component | Exposes current every-four-roots wrap and ordering discontinuity. No overlapping cards, reasonable route/area bounds and deterministic repeated result. |
|
||||
| OA/SA nested Allocation with ports, uniform subtree, folded ancestor and hidden route | Child interiors below reserved headers/footer, anchors on owner boundary, no fabricated ports, no semantic reassignment; hidden-route policy explicit; other view exact. |
|
||||
| Traceability mixed OA/SA with duplicate placement sentinels elsewhere | Flat visual graph stays flat, valid edge identities and other diagram exact. |
|
||||
| State machine with composite State, region, entry/exit boundary, internal/action rows and self-transition | State-specific legal boundary crossings, row reservations and no overwritten semantic state; shared Layout initialization remains valid even without a State Auto-layout button. |
|
||||
| Long ordinary/association labels, adjacent parallel labels, and #36 B/W/U State cases once integrated | Independently measure label/node, label/label and unrelated stroke/label intersections using actual displayed geometry; native ink/readability check separate from advance-box arithmetic. |
|
||||
| Dense/nonplanar or contradictory-anchor stress case | Finite time/work and explicit residual warnings/metrics; no claim all crossings can disappear. Preserve exact Undo and input on refusal. |
|
||||
|
||||
## Metrics and acceptance discipline
|
||||
|
||||
Report a vector, not a single opaque beauty score: (1) hard invalid geometry/illegal obstacle penetration and sibling overlap area; (2) label-node/label-label intersection area and other-edge strokes entering labels; (3) distinct proper edge-pair crossings; (4) positive-length collinear shared segments; (5) bend count after duplicate/collinear simplification, path length/stretch and total view bounds. Define endpoint/ancestor exemptions before running: parent-child containment is intentional, a shared semantic endpoint is not a crossing, owner leads and legal State boundary crossings are not nonincident obstacle violations. Deduplicate segment-pair crossing events at the same physical point; count edge pairs separately. Count near-parallel clearance as a separate readability observation, not zero-width intersection.
|
||||
|
||||
Use independent rectangle/segment math and full projected/routed outputs; export SVG and canvas must agree with those outputs. Include authored and derived labels, including association ends. A zero-crossing result obtained by coincident overlapping paths, gigantic detours, clipping labels or hiding edges fails the other metrics. Set fixture-specific route/area ceilings from a feasible manually declared witness rather than arbitrary post-hoc thresholds. Compare a second Arrange for idempotence, input-order permutation for determinism, pure camera changes for model invariance, then source Save/reopen and exact Undo/Redo.
|
||||
|
||||
Unavoidable cases: nonplanar graphs cannot all be planar with distinct nonoverlapping edges; fixed attachment orders, nested boundaries, coincident semantic endpoints and required self-loops can constrain clearance. Existing authored labels can intentionally occlude nodes. Report constrained residuals honestly and avoid turning ordinary automatic layout into deletion of semantic edges or impossible universal no-crossing guarantees.
|
||||
|
||||
The first useful milestone is a finite fixture/evaluator plus an explicit bend/label/hidden-route policy, then a prototype compared against current Layout. No need to expand into unrelated Process/Scenario geometry or redesign manual Arrange drag for this issue.
|
||||
Reference in New Issue
Block a user