experiment(layout): diagnose mandatory leads and test bounded flat separation
This commit is contained in:
@@ -5,6 +5,8 @@ use oa_core::{
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
#[path = "auto_layout_probe/clearance.rs"]
|
||||
mod clearance;
|
||||
#[path = "auto_layout_probe/layered.rs"]
|
||||
mod layered;
|
||||
|
||||
@@ -1002,6 +1004,94 @@ fn import_placement_cli(
|
||||
"final_projected_ports":projection::project_view(&after,diagram).ports})
|
||||
);
|
||||
}
|
||||
fn lead_diagnostics(project: &Project, diagram: Id) -> Value {
|
||||
let view = projection::project_view(project, diagram);
|
||||
let endpoint = |e: &routing::LeadEndpoint| {
|
||||
json!({"owner":e.owner,"tip":e.tip,"stub":e.stub,
|
||||
"blockers":e.blockers.iter().map(|b|json!({"id":b.id,"bounds":{"position":b.bounds.position,"size":b.bounds.size}})).collect::<Vec<_>>(),
|
||||
"stub_containers":e.stub_containers.iter().map(|b|json!({"id":b.id,"bounds":{"position":b.bounds.position,"size":b.bounds.size}})).collect::<Vec<_>>()})
|
||||
};
|
||||
json!(
|
||||
routing::lead_incompatibilities(project, &project.diagrams[&diagram], &view)
|
||||
.iter()
|
||||
.map(
|
||||
|r| json!({"edge":r.edge,"allowed_pair_count":r.allowed_pair_count,
|
||||
"lead_clear_pair_count":r.lead_clear_pair_count,
|
||||
"source":r.source.iter().map(&endpoint).collect::<Vec<_>>(),
|
||||
"target":r.target.iter().map(&endpoint).collect::<Vec<_>>()})
|
||||
)
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
}
|
||||
|
||||
fn repaired_candidate(before: &Project, diagram: Id) -> Result<Project, ModelError> {
|
||||
let geometry = clearance::repair(before, diagram)?;
|
||||
let view = projection::project_view(&geometry, diagram);
|
||||
// Reconstruct metadata once from original to final geometry with the same
|
||||
// audited reset/translation policy used by the placement reference.
|
||||
import_placement(
|
||||
before,
|
||||
diagram,
|
||||
PlacementPlan {
|
||||
schema: 1,
|
||||
nodes: view
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| PlacementNode {
|
||||
id: n.id,
|
||||
position: n.position,
|
||||
size: n.size,
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn repair_placement_cli(project_file: &std::path::Path, diagram_text: &std::ffi::OsStr) {
|
||||
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");
|
||||
validate_candidate(&before).expect("Validate input");
|
||||
assert!(before.diagrams.contains_key(&diagram), "Missing diagram");
|
||||
let candidate = match repaired_candidate(&before, diagram) {
|
||||
Err(error) => json!({"supported":false,"error":error.to_string()}),
|
||||
Ok(after) => {
|
||||
let duplicate = repaired_candidate(&before, diagram);
|
||||
let mut previous = after.clone();
|
||||
let mut stability = Vec::new();
|
||||
for pass in 2..=10 {
|
||||
match repaired_candidate(&previous, diagram) {
|
||||
Ok(next) => {
|
||||
stability.push(json!({"pass":pass,"equal_previous":next==previous,"equal_first":next==after}));
|
||||
previous = next;
|
||||
}
|
||||
Err(error) => {
|
||||
stability.push(json!({"pass":pass,"error":error.to_string()}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({"supported":true,"project":after,"metrics":metrics(&after,diagram),
|
||||
"lead_incompatibilities":lead_diagnostics(&after,diagram),
|
||||
"conservation":conservation(&before,&after,diagram),"roundtrip":roundtrip(&after),
|
||||
"same_input_exact":duplicate.as_ref().is_ok_and(|p|*p==after),
|
||||
"ten_repair_calls":stability})
|
||||
}
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
json!({"probe":"placement_clearance_v1","diagram":diagram,
|
||||
"scope":"Experimental flat monotone separation only; lead clearance is necessary, not sufficient routing or readability acceptance. Unsupported topology and unresolved constraints remain explicit. Existing router and command behavior unchanged.",
|
||||
"before_project":before,"before":metrics(&before,diagram),
|
||||
"before_lead_incompatibilities":lead_diagnostics(&before,diagram),"candidate":candidate})
|
||||
);
|
||||
}
|
||||
|
||||
fn arrange_corpus(path: &std::path::Path) {
|
||||
let manifest: Value = serde_json::from_slice(&std::fs::read(path).expect("Read manifest"))
|
||||
.expect("Decode manifest");
|
||||
@@ -1107,10 +1197,14 @@ fn main() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if args.len() == 4 && args[1] == "--repair-placement" {
|
||||
repair_placement_cli(std::path::Path::new(&args[2]), &args[3]);
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
args.len(),
|
||||
1,
|
||||
"Usage: auto_layout_probe [--route-corpus MANIFEST | --arrange-corpus MANIFEST | --import-placement PROJECT UUID PLAN]"
|
||||
"Usage: auto_layout_probe [--route-corpus MANIFEST | --arrange-corpus MANIFEST | --import-placement PROJECT UUID PLAN | --repair-placement PROJECT UUID]"
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Bounded flat-placement experiment. Empty lead diagnostics are necessary only,
|
||||
//! never a claim that routes, labels, or drawing quality are acceptable.
|
||||
use oa_core::{Id, ModelError, Project, projection, routing};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const ROUNDS: usize = 4;
|
||||
const WORK_LIMIT: usize = 10_000_000;
|
||||
const MAX_SPAN: f32 = 100_000.0;
|
||||
const MARGIN: f32 = 1.0;
|
||||
|
||||
fn fail(message: &str) -> ModelError {
|
||||
ModelError(format!("Clearance probe: {message}"))
|
||||
}
|
||||
|
||||
fn spend(work: &mut usize, amount: usize) -> Result<(), ModelError> {
|
||||
*work = work.saturating_add(amount);
|
||||
if *work > WORK_LIMIT {
|
||||
return Err(fail("work limit exceeded"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// A boundary constraint means start[to] >= start[from] + extent[from] + gap.
|
||||
// Adjacent total-order constraints below separately preserve start ordering.
|
||||
type Constraints = BTreeMap<(usize, usize), f32>;
|
||||
|
||||
fn solve(
|
||||
baseline: &[f32],
|
||||
extents: &[f32],
|
||||
order: &[usize],
|
||||
constraints: &Constraints,
|
||||
work: &mut usize,
|
||||
) -> Result<Vec<f32>, ModelError> {
|
||||
let mut result = baseline.to_vec();
|
||||
let mut previous = None;
|
||||
for &to in order {
|
||||
if let Some(from) = previous {
|
||||
result[to] = result[to].max(result[from]);
|
||||
}
|
||||
for (&(from, target), &gap) in constraints {
|
||||
spend(work, 1)?;
|
||||
if target == to {
|
||||
result[to] = result[to].max(result[from] + extents[from] + gap);
|
||||
}
|
||||
}
|
||||
previous = Some(to);
|
||||
}
|
||||
let minimum = baseline.iter().copied().fold(f32::INFINITY, f32::min);
|
||||
for (i, &value) in result.iter().enumerate() {
|
||||
if !value.is_finite() || value + extents[i] - minimum > MAX_SPAN {
|
||||
return Err(fail("coordinate/span bound exceeded"));
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Input is an already admitted placement candidate. Never modifies its routes,
|
||||
/// authored anchors, sizes or semantics. Error drops the private clone.
|
||||
pub fn repair(project: &Project, diagram: Id) -> Result<Project, ModelError> {
|
||||
let original = project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or_else(|| fail("missing diagram"))?;
|
||||
if original.projection.scenario().is_some() {
|
||||
return Err(fail("Scenario unsupported"));
|
||||
}
|
||||
let baseline = projection::project_diagram(project, original);
|
||||
if baseline.nodes.len() > 256 || baseline.edges.len() > 1024 {
|
||||
return Err(fail("graph bound exceeded"));
|
||||
}
|
||||
if baseline.nodes.len() != original.nodes.len()
|
||||
|| baseline
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.collapsed || n.visual_parent.is_some())
|
||||
{
|
||||
return Err(fail("nested, folded or omitted placements unsupported"));
|
||||
}
|
||||
let mut nodes = baseline.nodes.clone();
|
||||
nodes.sort_by_key(|n| n.id);
|
||||
if nodes.iter().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(fail("invalid initial geometry"));
|
||||
}
|
||||
let indices: BTreeMap<_, _> = nodes.iter().enumerate().map(|(i, n)| (n.id, i)).collect();
|
||||
let coordinates = [
|
||||
nodes.iter().map(|n| n.position.x).collect::<Vec<_>>(),
|
||||
nodes.iter().map(|n| n.position.y).collect::<Vec<_>>(),
|
||||
];
|
||||
let extents = [
|
||||
nodes.iter().map(|n| n.size.width).collect::<Vec<_>>(),
|
||||
nodes.iter().map(|n| n.size.height).collect::<Vec<_>>(),
|
||||
];
|
||||
for axis in 0..2 {
|
||||
let minimum = coordinates[axis]
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
if coordinates[axis]
|
||||
.iter()
|
||||
.zip(&extents[axis])
|
||||
.any(|(&start, &extent)| {
|
||||
!(start + extent).is_finite() || start + extent - minimum > MAX_SPAN
|
||||
})
|
||||
{
|
||||
return Err(fail("initial coordinate/span bound exceeded"));
|
||||
}
|
||||
}
|
||||
let mut orders = [Vec::new(), Vec::new()];
|
||||
let mut ranks = [vec![0; nodes.len()], vec![0; nodes.len()]];
|
||||
for axis in 0..2 {
|
||||
orders[axis] = (0..nodes.len()).collect();
|
||||
orders[axis].sort_by(|&a, &b| {
|
||||
coordinates[axis][a]
|
||||
.total_cmp(&coordinates[axis][b])
|
||||
.then(nodes[a].id.cmp(&nodes[b].id))
|
||||
});
|
||||
for (rank, &i) in orders[axis].iter().enumerate() {
|
||||
ranks[axis][i] = rank;
|
||||
}
|
||||
}
|
||||
let mut constraints = [Constraints::new(), Constraints::new()];
|
||||
let mut work = 0;
|
||||
// Preserve one existing disjoint axis per pair, X before Y. No initially
|
||||
// overlapping pair is silently treated as successfully repaired.
|
||||
for a in 0..nodes.len() {
|
||||
for b in a + 1..nodes.len() {
|
||||
spend(&mut work, 1)?;
|
||||
let mut chosen = None;
|
||||
for axis in 0..2 {
|
||||
for (from, to) in [(a, b), (b, a)] {
|
||||
let gap = coordinates[axis][to] - coordinates[axis][from] - extents[axis][from];
|
||||
if gap >= 0.0 {
|
||||
chosen = Some((axis, from, to, gap));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if chosen.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (axis, from, to, gap) =
|
||||
chosen.ok_or_else(|| fail("initial node overlap unsupported"))?;
|
||||
constraints[axis].insert((from, to), gap);
|
||||
}
|
||||
}
|
||||
let mut result = project.clone();
|
||||
for round in 0..=ROUNDS {
|
||||
let current = &result.diagrams[&diagram];
|
||||
let view = projection::project_diagram(&result, current);
|
||||
if view.nodes.len() != nodes.len()
|
||||
|| view.nodes.iter().any(|n| {
|
||||
indices.get(&n.id).is_none_or(|&i| {
|
||||
n.size != nodes[i].size || n.collapsed || n.visual_parent.is_some()
|
||||
})
|
||||
})
|
||||
{
|
||||
return Err(fail("reprojection changed node identity/size/topology"));
|
||||
}
|
||||
// Charge a conservative endpoint-pair/obstacle allowance before the
|
||||
// diagnostic: at most four cardinal candidates at each endpoint.
|
||||
spend(
|
||||
&mut work,
|
||||
view.edges
|
||||
.len()
|
||||
.saturating_mul(nodes.len().saturating_add(1))
|
||||
.saturating_mul(16),
|
||||
)?;
|
||||
let failures = routing::lead_incompatibilities(&result, current, &view);
|
||||
if failures.is_empty() {
|
||||
return Ok(result);
|
||||
}
|
||||
if round == ROUNDS {
|
||||
return Err(fail("unresolved mandatory leads after four adjustments"));
|
||||
}
|
||||
let mut strengthened = false;
|
||||
for failure in failures {
|
||||
if failure.allowed_pair_count != 1
|
||||
|| failure.source.len() != 1
|
||||
|| failure.target.len() != 1
|
||||
{
|
||||
return Err(fail("ambiguous endpoint choices unsupported"));
|
||||
}
|
||||
for endpoint in failure.source.iter().chain(&failure.target) {
|
||||
let owner = *indices
|
||||
.get(&endpoint.owner)
|
||||
.ok_or_else(|| fail("unknown endpoint owner"))?;
|
||||
let dx = endpoint.stub.x - endpoint.tip.x;
|
||||
let dy = endpoint.stub.y - endpoint.tip.y;
|
||||
if endpoint.blockers.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (axis, forward) = match (dx, dy) {
|
||||
(x, 0.0) if x != 0.0 => (0, x > 0.0),
|
||||
(0.0, y) if y != 0.0 => (1, y > 0.0),
|
||||
_ => return Err(fail("noncardinal/degenerate blocked lead")),
|
||||
};
|
||||
for blocker in &endpoint.blockers {
|
||||
spend(&mut work, 1)?;
|
||||
let other = *indices
|
||||
.get(&blocker.id)
|
||||
.ok_or_else(|| fail("unknown blocker"))?;
|
||||
let (from, to) = if forward {
|
||||
(owner, other)
|
||||
} else {
|
||||
(other, owner)
|
||||
};
|
||||
if ranks[axis][from] >= ranks[axis][to] {
|
||||
return Err(fail("lead constraint opposes frozen spatial order"));
|
||||
}
|
||||
let required = routing::PORT_STUB + routing::CLEARANCE + MARGIN;
|
||||
let old = constraints[axis].entry((from, to)).or_insert(0.0);
|
||||
if *old < required {
|
||||
*old = required;
|
||||
strengthened = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strengthened {
|
||||
return Err(fail(
|
||||
"unresolved lead without a stronger monotone constraint",
|
||||
));
|
||||
}
|
||||
let x = solve(
|
||||
&coordinates[0],
|
||||
&extents[0],
|
||||
&orders[0],
|
||||
&constraints[0],
|
||||
&mut work,
|
||||
)?;
|
||||
let y = solve(
|
||||
&coordinates[1],
|
||||
&extents[1],
|
||||
&orders[1],
|
||||
&constraints[1],
|
||||
&mut work,
|
||||
)?;
|
||||
let target = result.diagrams.get_mut(&diagram).unwrap();
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
let placed = target.nodes.get_mut(&node.id).unwrap();
|
||||
placed.position.x = x[i];
|
||||
placed.position.y = y[i];
|
||||
}
|
||||
}
|
||||
unreachable!("bounded loop returns on final round")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn separation_propagates_to_neighbor_without_moving_predecessor() {
|
||||
let constraints = BTreeMap::from([((0, 1), 31.0), ((1, 2), 10.0)]);
|
||||
let actual = solve(
|
||||
&[-20.0, 100.0, 210.0],
|
||||
&[100.0; 3],
|
||||
&[0, 1, 2],
|
||||
&constraints,
|
||||
&mut 0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(actual, vec![-20.0, 111.0, 221.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extent_bound_rejects_constraint_expansion() {
|
||||
let constraints = BTreeMap::from([((0, 1), MAX_SPAN)]);
|
||||
assert!(solve(&[0.0, 20.0], &[10.0; 2], &[0, 1], &constraints, &mut 0).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user