Add bounded external placement comparison and preservation adapter
This commit is contained in:
@@ -794,6 +794,246 @@ fn route_corpus(path: &std::path::Path) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// External placement is an experimental geometry transfer, not a production command.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PlacementPlan {
|
||||
schema: u32,
|
||||
nodes: Vec<PlacementNode>,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PlacementNode {
|
||||
id: Id,
|
||||
position: Position,
|
||||
size: Size,
|
||||
}
|
||||
fn import_placement(
|
||||
original: &Project,
|
||||
diagram: Id,
|
||||
plan: PlacementPlan,
|
||||
) -> Result<Project, ModelError> {
|
||||
let fail = |message: &str| ModelError(message.to_owned());
|
||||
validate_candidate(original)?;
|
||||
let d = original
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or_else(|| fail("Missing diagram"))?;
|
||||
if d.projection.scenario().is_some() {
|
||||
return Err(fail("Scenario placement import is unsupported"));
|
||||
}
|
||||
if plan.schema != 1 || d.nodes.len() > 256 || plan.nodes.len() > 256 {
|
||||
return Err(fail("Placement schema or 256-node bound exceeded"));
|
||||
}
|
||||
let view = projection::project_view(original, diagram);
|
||||
if view.edges.len() > 1024 {
|
||||
return Err(fail("Placement 1024-visible-edge bound exceeded"));
|
||||
}
|
||||
let visible: BTreeSet<_> = view.nodes.iter().map(|node| node.id).collect();
|
||||
let mut supplied = BTreeSet::new();
|
||||
for node in &plan.nodes {
|
||||
if !supplied.insert(node.id)
|
||||
|| !visible.contains(&node.id)
|
||||
|| !node.position.x.is_finite()
|
||||
|| !node.position.y.is_finite()
|
||||
|| !node.size.width.is_finite()
|
||||
|| !node.size.height.is_finite()
|
||||
|| node.size.width <= 0.0
|
||||
|| node.size.height <= 0.0
|
||||
{
|
||||
return Err(fail("Invalid, duplicate or out-of-scope placement"));
|
||||
}
|
||||
}
|
||||
if supplied != visible {
|
||||
return Err(fail(
|
||||
"Plan must contain every projected visible node exactly once",
|
||||
));
|
||||
}
|
||||
let eligible: BTreeSet<_> = view
|
||||
.edges
|
||||
.iter()
|
||||
.filter_map(|edge| {
|
||||
let owners = projection::edge_endpoint_placements(original, d, edge.id)?;
|
||||
(owners == (edge.source, edge.target)
|
||||
&& visible.contains(&edge.source)
|
||||
&& visible.contains(&edge.target))
|
||||
.then_some(edge.id)
|
||||
})
|
||||
.collect();
|
||||
// Recover core visual ancestry, including Allocation grouping, without
|
||||
// reimplementing private parents(). This temporary clone is never returned.
|
||||
let mut unfolded = original.clone();
|
||||
for node in unfolded
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
{
|
||||
node.collapsed = false;
|
||||
}
|
||||
let full = projection::project_view(&unfolded, diagram);
|
||||
let parents: BTreeMap<_, _> = full.nodes.iter().map(|n| (n.id, n.visual_parent)).collect();
|
||||
let mut hidden_owner = BTreeMap::new();
|
||||
for id in parents.keys() {
|
||||
let mut seen = BTreeSet::from([*id]);
|
||||
let mut parent = parents[id];
|
||||
let mut nearest = None;
|
||||
while let Some(ancestor) = parent {
|
||||
if seen.len() > 256 || !seen.insert(ancestor) {
|
||||
return Err(fail("Invalid cyclic or excessive visual ancestry"));
|
||||
}
|
||||
if nearest.is_none() && visible.contains(&ancestor) && d.nodes[&ancestor].collapsed {
|
||||
nearest = Some(ancestor);
|
||||
}
|
||||
parent = *parents
|
||||
.get(&ancestor)
|
||||
.ok_or_else(|| fail("Missing visual ancestor"))?;
|
||||
}
|
||||
if !visible.contains(id)
|
||||
&& let Some(owner) = nearest
|
||||
{
|
||||
hidden_owner.insert(*id, owner);
|
||||
}
|
||||
}
|
||||
let mut after = original.clone();
|
||||
let ad = after.diagrams.get_mut(&diagram).unwrap();
|
||||
for node in plan.nodes {
|
||||
let placed = ad
|
||||
.nodes
|
||||
.get_mut(&node.id)
|
||||
.ok_or_else(|| fail("Projected node is not stored"))?;
|
||||
if placed.collapsed {
|
||||
let compact = view.nodes.iter().find(|n| n.id == node.id).unwrap().size;
|
||||
if node.size != compact {
|
||||
return Err(fail(
|
||||
"Collapsed placement plan must retain exact projected compact size; stored expanded size is preserved",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
placed.size = node.size;
|
||||
}
|
||||
placed.position = node.position;
|
||||
}
|
||||
for (id, owner) in hidden_owner {
|
||||
let delta = Position {
|
||||
x: ad.nodes[&owner].position.x - d.nodes[&owner].position.x,
|
||||
y: ad.nodes[&owner].position.y - d.nodes[&owner].position.y,
|
||||
};
|
||||
let hidden = ad.nodes.get_mut(&id).unwrap();
|
||||
hidden.position.x = d.nodes[&id].position.x + delta.x;
|
||||
hidden.position.y = d.nodes[&id].position.y + delta.y;
|
||||
}
|
||||
let deltas: BTreeMap<_, _> = ad
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|(id, n)| {
|
||||
(
|
||||
*id,
|
||||
Position {
|
||||
x: n.position.x - d.nodes[id].position.x,
|
||||
y: n.position.y - d.nodes[id].position.y,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let translated: BTreeMap<_, _> = projection::translated_routes(original, d, &deltas)
|
||||
.into_iter()
|
||||
.map(|route| (route.edge, route))
|
||||
.collect();
|
||||
for route in &mut ad.routes {
|
||||
if eligible.contains(&route.edge) {
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
} else if let Some(moved) = translated.get(&route.edge) {
|
||||
*route = moved.clone();
|
||||
}
|
||||
}
|
||||
for node in ad.nodes.values() {
|
||||
if !node.position.x.is_finite() || !node.position.y.is_finite() {
|
||||
return Err(fail("Placement translation overflow"));
|
||||
}
|
||||
}
|
||||
validate_candidate(&after)?;
|
||||
Ok(after)
|
||||
}
|
||||
fn import_placement_cli(
|
||||
project_file: &std::path::Path,
|
||||
diagram_text: &std::ffi::OsStr,
|
||||
plan_file: &std::path::Path,
|
||||
) {
|
||||
let before: Project =
|
||||
serde_json::from_slice(&std::fs::read(project_file).expect("Read Project"))
|
||||
.expect("Decode Project");
|
||||
let diagram: Id = diagram_text
|
||||
.to_str()
|
||||
.expect("UTF-8 UUID")
|
||||
.parse()
|
||||
.expect("Diagram UUID");
|
||||
let plan: PlacementPlan =
|
||||
serde_json::from_slice(&std::fs::read(plan_file).expect("Read placement plan"))
|
||||
.expect("Decode placement plan");
|
||||
let after = import_placement(&before, diagram, plan).expect("Validate placement transfer");
|
||||
println!(
|
||||
"{}",
|
||||
json!({"probe":"external_placement_v1", "diagram":diagram,
|
||||
"scope":"Visible positions and expanded-node sizes imported; collapsed planned size must equal original projected compact size, stored expanded size preserved. Hidden descendants translated with nearest visible collapsed ancestor; unrelated omitted placements exact. Existing eligible routes reset, retained routes use original-to-final translation. No ELK paths, labels or ports imported; no Layout executed.",
|
||||
"before_project":before,"after_project":after,"before":metrics(&before,diagram),"after":metrics(&after,diagram),
|
||||
"conservation":conservation(&before,&after,diagram),"roundtrip":roundtrip(&after),
|
||||
"final_projected_ports":projection::project_view(&after,diagram).ports})
|
||||
);
|
||||
}
|
||||
fn arrange_corpus(path: &std::path::Path) {
|
||||
let manifest: Value = serde_json::from_slice(&std::fs::read(path).expect("Read manifest"))
|
||||
.expect("Decode manifest");
|
||||
assert_eq!(manifest["schema"], 1);
|
||||
let mut names = BTreeSet::new();
|
||||
let cases = manifest["cases"].as_array().expect("Cases");
|
||||
assert!(!cases.is_empty(), "Empty corpus");
|
||||
println!(
|
||||
"{}",
|
||||
json!({"probe":"arrange_corpus_v1","manifest":manifest,"quality_thresholds":null})
|
||||
);
|
||||
for case in cases {
|
||||
let name = case["name"].as_str().expect("Case name");
|
||||
assert!(names.insert(name), "Duplicate case name");
|
||||
let file = std::path::Path::new(case["project_file"].as_str().expect("Project file"));
|
||||
assert!(
|
||||
file.components()
|
||||
.all(|c| matches!(c, std::path::Component::Normal(_))),
|
||||
"Relative normal project path required"
|
||||
);
|
||||
let project: Project = serde_json::from_slice(
|
||||
&std::fs::read(path.parent().unwrap().join(file)).expect("Read Project"),
|
||||
)
|
||||
.expect("Decode Project");
|
||||
let diagram: Id = serde_json::from_value(case["diagram"].clone()).expect("Diagram UUID");
|
||||
validate_candidate(&project).expect("Validate corpus Project");
|
||||
assert!(project.diagrams.contains_key(&diagram), "Missing diagram");
|
||||
assert!(
|
||||
project.diagrams[&diagram].nodes.len() <= 256,
|
||||
"256-node bound"
|
||||
);
|
||||
assert!(
|
||||
projection::project_view(&project, diagram).edges.len() <= 1024,
|
||||
"1024-edge bound"
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
observe(
|
||||
name,
|
||||
Builder {
|
||||
project,
|
||||
diagram,
|
||||
next: 0,
|
||||
witness: None
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Analytic checks validate the observer, not any layout's quality.
|
||||
let horizontal = [(0., 0.), (10., 0.)];
|
||||
@@ -837,10 +1077,22 @@ fn main() {
|
||||
route_corpus(std::path::Path::new(&args[2]));
|
||||
return;
|
||||
}
|
||||
if args.len() == 3 && args[1] == "--arrange-corpus" {
|
||||
arrange_corpus(std::path::Path::new(&args[2]));
|
||||
return;
|
||||
}
|
||||
if args.len() == 5 && args[1] == "--import-placement" {
|
||||
import_placement_cli(
|
||||
std::path::Path::new(&args[2]),
|
||||
&args[3],
|
||||
std::path::Path::new(&args[4]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
args.len(),
|
||||
1,
|
||||
"Usage: auto_layout_probe [--route-corpus MANIFEST]"
|
||||
"Usage: auto_layout_probe [--route-corpus MANIFEST | --arrange-corpus MANIFEST | --import-placement PROJECT UUID PLAN]"
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
|
||||
Reference in New Issue
Block a user