experiment(layout): keep cyclic quotient groups compact in condensation order
This commit is contained in:
@@ -7,6 +7,8 @@ use serde_json::{Value, json};
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
#[path = "auto_layout_probe/clearance.rs"]
|
#[path = "auto_layout_probe/clearance.rs"]
|
||||||
mod clearance;
|
mod clearance;
|
||||||
|
#[path = "auto_layout_probe/condensed.rs"]
|
||||||
|
mod condensed;
|
||||||
#[path = "auto_layout_probe/layered.rs"]
|
#[path = "auto_layout_probe/layered.rs"]
|
||||||
mod layered;
|
mod layered;
|
||||||
|
|
||||||
@@ -1092,6 +1094,61 @@ fn repair_placement_cli(project_file: &std::path::Path, diagram_text: &std::ffi:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn scc_observe(name: &str, f: Builder) -> Value {
|
||||||
|
let before = f.project;
|
||||||
|
let diagram = f.diagram;
|
||||||
|
validate_candidate(&before).expect("Validate SCC input");
|
||||||
|
let candidate = match condensed::candidate_with_diagnostics(&before, diagram) {
|
||||||
|
Err(error) => json!({"supported":false,"error":error.to_string()}),
|
||||||
|
Ok((after, plan)) => {
|
||||||
|
let same = condensed::candidate(&before, diagram);
|
||||||
|
let mut repeated = after.clone();
|
||||||
|
let mut stability = Vec::new();
|
||||||
|
for pass in 2..=10 {
|
||||||
|
match condensed::candidate(&repeated, diagram) {
|
||||||
|
Ok(next) => {
|
||||||
|
stability.push(json!({"pass":pass,"equal_previous":next==repeated,"equal_first":next==after}));
|
||||||
|
repeated = next;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
stability.push(json!({"pass":pass,"error":error.to_string()}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
json!({"supported":true,"project":after,"plan":plan,"metrics":metrics(&after,diagram),
|
||||||
|
"lead_incompatibilities":lead_diagnostics(&after,diagram),
|
||||||
|
"conservation":conservation(&before,&after,diagram),"roundtrip":roundtrip(&after),
|
||||||
|
"same_input_exact":same.as_ref().is_ok_and(|p|*p==after),"ten_candidate_calls":stability})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
json!({"fixture":name,"diagram":diagram,"before_project":before,
|
||||||
|
"before":metrics(&before,diagram),"candidate":candidate})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scc_placement_cli(project_file: &std::path::Path, diagram_text: &std::ffi::OsStr) {
|
||||||
|
let project: 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");
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
scc_observe(
|
||||||
|
"external_case",
|
||||||
|
Builder {
|
||||||
|
project,
|
||||||
|
diagram,
|
||||||
|
next: 0,
|
||||||
|
witness: None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn arrange_corpus(path: &std::path::Path) {
|
fn arrange_corpus(path: &std::path::Path) {
|
||||||
let manifest: Value = serde_json::from_slice(&std::fs::read(path).expect("Read manifest"))
|
let manifest: Value = serde_json::from_slice(&std::fs::read(path).expect("Read manifest"))
|
||||||
.expect("Decode manifest");
|
.expect("Decode manifest");
|
||||||
@@ -1201,10 +1258,20 @@ fn main() {
|
|||||||
repair_placement_cli(std::path::Path::new(&args[2]), &args[3]);
|
repair_placement_cli(std::path::Path::new(&args[2]), &args[3]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if args.len() == 4 && args[1] == "--scc-placement" {
|
||||||
|
scc_placement_cli(std::path::Path::new(&args[2]), &args[3]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if args.len() == 2 && args[1] == "--scc-fixtures" {
|
||||||
|
for (name, f) in fixtures() {
|
||||||
|
println!("{}", scc_observe(name, f));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
args.len(),
|
args.len(),
|
||||||
1,
|
1,
|
||||||
"Usage: auto_layout_probe [--route-corpus MANIFEST | --arrange-corpus MANIFEST | --import-placement PROJECT UUID PLAN | --repair-placement PROJECT UUID]"
|
"Usage: auto_layout_probe [--route-corpus MANIFEST | --arrange-corpus MANIFEST | --import-placement PROJECT UUID PLAN | --repair-placement PROJECT UUID | --scc-placement PROJECT UUID | --scc-fixtures]"
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
|
|||||||
@@ -0,0 +1,551 @@
|
|||||||
|
//! Isolated SCC-block placement experiment; no production-layout acceptance.
|
||||||
|
use super::{PlacementNode, PlacementPlan, import_placement};
|
||||||
|
use oa_core::{Id, ModelError, Position, Project, Size, projection, routing};
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
const LIMIT: usize = 20_000_000;
|
||||||
|
const PAD: f32 = 32.0;
|
||||||
|
const SPAN: f32 = 100_000.0;
|
||||||
|
|
||||||
|
fn error(text: &str) -> ModelError {
|
||||||
|
ModelError(format!("SCC placement: {text}"))
|
||||||
|
}
|
||||||
|
fn charge(work: &mut usize, count: usize) -> Result<(), ModelError> {
|
||||||
|
*work = work.saturating_add(count);
|
||||||
|
if *work > LIMIT {
|
||||||
|
Err(error("work limit exceeded"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn components(arcs: &[BTreeSet<usize>], work: &mut usize) -> Result<Vec<Vec<usize>>, ModelError> {
|
||||||
|
let n = arcs.len();
|
||||||
|
let mut reach = vec![vec![false; n]; n];
|
||||||
|
for start in 0..n {
|
||||||
|
let mut todo = vec![start];
|
||||||
|
reach[start][start] = true;
|
||||||
|
while let Some(at) = todo.pop() {
|
||||||
|
for &next in &arcs[at] {
|
||||||
|
charge(work, 1)?;
|
||||||
|
if !reach[start][next] {
|
||||||
|
reach[start][next] = true;
|
||||||
|
todo.push(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut used = vec![false; n];
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for i in 0..n {
|
||||||
|
if used[i] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut group = Vec::new();
|
||||||
|
for j in i..n {
|
||||||
|
charge(work, 1)?;
|
||||||
|
if !used[j] && reach[i][j] && reach[j][i] {
|
||||||
|
used[j] = true;
|
||||||
|
group.push(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(group);
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clockwise perimeter slots. Restrict to near-square physical envelopes when
|
||||||
|
// possible; minimizing perimeter area without this restriction makes a strip.
|
||||||
|
fn perimeter(
|
||||||
|
n: usize,
|
||||||
|
cell: Size,
|
||||||
|
gap: Size,
|
||||||
|
work: &mut usize,
|
||||||
|
) -> Result<(Vec<Position>, usize, usize), ModelError> {
|
||||||
|
let mut best: Option<((bool, f32, f32, usize, usize), usize, usize)> = None;
|
||||||
|
for rows in 2..=n.max(2) {
|
||||||
|
for cols in 2..=n.max(2) {
|
||||||
|
charge(work, 1)?;
|
||||||
|
if 2 * rows + 2 * cols - 4 < n {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let width = cols as f32 * cell.width + (cols - 1) as f32 * gap.width;
|
||||||
|
let height = rows as f32 * cell.height + (rows - 1) as f32 * gap.height;
|
||||||
|
let aspect = (width / height).max(height / width);
|
||||||
|
let key = (
|
||||||
|
aspect > 2.0,
|
||||||
|
if aspect <= 2.0 {
|
||||||
|
width * height
|
||||||
|
} else {
|
||||||
|
aspect
|
||||||
|
},
|
||||||
|
width * height,
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
);
|
||||||
|
if best.as_ref().is_none_or(|(old, _, _)| key < *old) {
|
||||||
|
best = Some((key, rows, cols));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (_, rows, cols) = best.ok_or_else(|| error("no perimeter grid"))?;
|
||||||
|
let mut slots = Vec::new();
|
||||||
|
for col in 0..cols {
|
||||||
|
slots.push((col, 0));
|
||||||
|
}
|
||||||
|
for row in 1..rows {
|
||||||
|
slots.push((cols - 1, row));
|
||||||
|
}
|
||||||
|
for col in (0..cols - 1).rev() {
|
||||||
|
slots.push((col, rows - 1));
|
||||||
|
}
|
||||||
|
for row in (1..rows - 1).rev() {
|
||||||
|
slots.push((0, row));
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
slots
|
||||||
|
.into_iter()
|
||||||
|
.take(n)
|
||||||
|
.map(|(x, y)| Position {
|
||||||
|
x: x as f32 * (cell.width + gap.width),
|
||||||
|
y: y as f32 * (cell.height + gap.height),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lift(
|
||||||
|
id: Id,
|
||||||
|
parent: Option<Id>,
|
||||||
|
parents: &BTreeMap<Id, Option<Id>>,
|
||||||
|
work: &mut usize,
|
||||||
|
) -> Result<Option<Id>, ModelError> {
|
||||||
|
let mut at = id;
|
||||||
|
for _ in 0..=parents.len() {
|
||||||
|
charge(work, 1)?;
|
||||||
|
let Some(&up) = parents.get(&at) else {
|
||||||
|
return Err(error("missing visual ancestor"));
|
||||||
|
};
|
||||||
|
if up == parent {
|
||||||
|
return Ok(Some(at));
|
||||||
|
}
|
||||||
|
let Some(next) = up else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
at = next;
|
||||||
|
}
|
||||||
|
Err(error("cyclic visual ancestry"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frozen projected geometry is the minimum; folded nodes remain opaque.
|
||||||
|
pub fn candidate(project: &Project, diagram: Id) -> Result<Project, ModelError> {
|
||||||
|
candidate_with_diagnostics(project, diagram).map(|(result, _)| result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn candidate_with_diagnostics(
|
||||||
|
project: &Project,
|
||||||
|
diagram: Id,
|
||||||
|
) -> Result<(Project, serde_json::Value), ModelError> {
|
||||||
|
let d = project
|
||||||
|
.diagrams
|
||||||
|
.get(&diagram)
|
||||||
|
.ok_or_else(|| error("missing diagram"))?;
|
||||||
|
if d.projection.scenario().is_some() {
|
||||||
|
return Err(error("Scenario unsupported"));
|
||||||
|
}
|
||||||
|
if d.nodes.len() > 256 {
|
||||||
|
return Err(error("stored-node bound exceeded"));
|
||||||
|
}
|
||||||
|
let view = projection::project_diagram(project, d);
|
||||||
|
if view.edges.len() > 1024 {
|
||||||
|
return Err(error("visible-edge bound exceeded"));
|
||||||
|
}
|
||||||
|
if view.nodes.iter().any(|n| {
|
||||||
|
!n.size.width.is_finite()
|
||||||
|
|| !n.size.height.is_finite()
|
||||||
|
|| n.size.width <= 0.0
|
||||||
|
|| n.size.height <= 0.0
|
||||||
|
}) {
|
||||||
|
return Err(error("invalid projected minima"));
|
||||||
|
}
|
||||||
|
let parents: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n.visual_parent)).collect();
|
||||||
|
let names: BTreeMap<_, _> = view
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.map(|n| (n.id, (project.elements[&n.element].alias.clone(), n.id)))
|
||||||
|
.collect();
|
||||||
|
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
|
||||||
|
for n in &view.nodes {
|
||||||
|
children.entry(n.visual_parent).or_default().push(n.id);
|
||||||
|
}
|
||||||
|
for ids in children.values_mut() {
|
||||||
|
ids.sort_by_key(|id| names[id].clone());
|
||||||
|
}
|
||||||
|
let mut work = 0;
|
||||||
|
let mut depth = BTreeMap::new();
|
||||||
|
for n in &view.nodes {
|
||||||
|
let mut at = n.id;
|
||||||
|
let mut count = 0;
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
while let Some(up) = *parents.get(&at).ok_or_else(|| error("missing parent"))? {
|
||||||
|
charge(&mut work, 1)?;
|
||||||
|
if !seen.insert(at) {
|
||||||
|
return Err(error("cyclic hierarchy"));
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
at = up;
|
||||||
|
}
|
||||||
|
depth.insert(n.id, count);
|
||||||
|
}
|
||||||
|
let routed = routing::route_diagram(project, d, &view);
|
||||||
|
let mut sizes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n.size)).collect();
|
||||||
|
let mut offsets = BTreeMap::new();
|
||||||
|
let mut diagnostics = Vec::new();
|
||||||
|
let mut groups: Vec<_> = children.keys().copied().collect();
|
||||||
|
groups.sort_by_key(|p| std::cmp::Reverse(p.map_or(0, |id| depth[&id] + 1)));
|
||||||
|
for parent in groups {
|
||||||
|
let ids = &children[&parent];
|
||||||
|
let index: BTreeMap<_, _> = ids.iter().enumerate().map(|(i, id)| (*id, i)).collect();
|
||||||
|
let mut arcs = vec![BTreeSet::new(); ids.len()];
|
||||||
|
let mut links = Vec::new();
|
||||||
|
let mut undirected = BTreeSet::new();
|
||||||
|
for edge in &view.edges {
|
||||||
|
charge(&mut work, 1)?;
|
||||||
|
let (Some(a), Some(b)) = (
|
||||||
|
lift(edge.source, parent, &parents, &mut work)?,
|
||||||
|
lift(edge.target, parent, &parents, &mut work)?,
|
||||||
|
) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if a == b {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (a, b) = (index[&a], index[&b]);
|
||||||
|
if edge.oriented {
|
||||||
|
arcs[a].insert(b);
|
||||||
|
} else {
|
||||||
|
undirected.insert((ids[a].min(ids[b]), ids[a].max(ids[b])));
|
||||||
|
}
|
||||||
|
let routed_edge = routed
|
||||||
|
.edges
|
||||||
|
.get(&edge.id)
|
||||||
|
.ok_or_else(|| error("missing estimated edge label"))?;
|
||||||
|
let mut label = routed_edge.label.bounds.size;
|
||||||
|
if let Some(relation) = project.relations.get(&edge.id) {
|
||||||
|
for end in routing::association_end_geometry(relation, routed_edge) {
|
||||||
|
label.width = label.width.max(end.label.bounds.size.width);
|
||||||
|
label.height = label.height.max(end.label.bounds.size.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !label.width.is_finite() || !label.height.is_finite() {
|
||||||
|
return Err(error("invalid estimated label"));
|
||||||
|
}
|
||||||
|
links.push((a, b, label));
|
||||||
|
}
|
||||||
|
let comps = components(&arcs, &mut work)?;
|
||||||
|
let mut owner = vec![0; ids.len()];
|
||||||
|
for (c, members) in comps.iter().enumerate() {
|
||||||
|
for &i in members {
|
||||||
|
owner[i] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut dag = vec![BTreeSet::new(); comps.len()];
|
||||||
|
for (a, targets) in arcs.iter().enumerate() {
|
||||||
|
for &b in targets {
|
||||||
|
if owner[a] != owner[b] {
|
||||||
|
dag[owner[a]].insert(owner[b]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ranks = vec![0usize; comps.len()];
|
||||||
|
let mut done = BTreeSet::new();
|
||||||
|
while done.len() < comps.len() {
|
||||||
|
let next = (0..comps.len())
|
||||||
|
.find(|c| {
|
||||||
|
!done.contains(c)
|
||||||
|
&& (0..comps.len()).all(|p| !dag[p].contains(c) || done.contains(&p))
|
||||||
|
})
|
||||||
|
.ok_or_else(|| error("condensation is cyclic"))?;
|
||||||
|
charge(&mut work, comps.len() * comps.len())?;
|
||||||
|
for &to in &dag[next] {
|
||||||
|
ranks[to] = ranks[to].max(ranks[next] + 1);
|
||||||
|
}
|
||||||
|
done.insert(next);
|
||||||
|
}
|
||||||
|
let mut blocks = Vec::new();
|
||||||
|
let mut component_diagnostics = Vec::new();
|
||||||
|
let mut member_offsets = BTreeMap::new();
|
||||||
|
for (c, members) in comps.iter().enumerate() {
|
||||||
|
let mut gap = Size {
|
||||||
|
width: PAD,
|
||||||
|
height: PAD,
|
||||||
|
};
|
||||||
|
for &(a, b, label) in &links {
|
||||||
|
if owner[a] == c && owner[b] == c {
|
||||||
|
gap.width = gap.width.max(label.width + PAD);
|
||||||
|
gap.height = gap.height.max(label.height + PAD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut order = Vec::new();
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
for &start in members {
|
||||||
|
let mut stack = vec![start];
|
||||||
|
while let Some(i) = stack.pop() {
|
||||||
|
charge(&mut work, 1)?;
|
||||||
|
if !seen.insert(i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
order.push(i);
|
||||||
|
for &j in arcs[i].iter().rev() {
|
||||||
|
if owner[j] == c && !seen.contains(&j) {
|
||||||
|
stack.push(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let cell = Size {
|
||||||
|
width: members
|
||||||
|
.iter()
|
||||||
|
.map(|&i| sizes[&ids[i]].width)
|
||||||
|
.fold(0.0, f32::max),
|
||||||
|
height: members
|
||||||
|
.iter()
|
||||||
|
.map(|&i| sizes[&ids[i]].height)
|
||||||
|
.fold(0.0, f32::max),
|
||||||
|
};
|
||||||
|
let (slots, rows, cols) = if members.len() == 1 {
|
||||||
|
(vec![Position { x: 0.0, y: 0.0 }], 1, 1)
|
||||||
|
} else {
|
||||||
|
perimeter(members.len(), cell, gap, &mut work)?
|
||||||
|
};
|
||||||
|
component_diagnostics.push(serde_json::json!({"component":c,"rank":ranks[c],"members":members.iter().map(|&i|ids[i]).collect::<Vec<_>>(),"walk":order.iter().map(|&i|ids[i]).collect::<Vec<_>>(),"grid_rows":rows,"grid_cols":cols,"gap":gap}));
|
||||||
|
let mut size = Size {
|
||||||
|
width: 0.0,
|
||||||
|
height: 0.0,
|
||||||
|
};
|
||||||
|
for (i, pos) in order.into_iter().zip(slots) {
|
||||||
|
size.width = size.width.max(pos.x + sizes[&ids[i]].width);
|
||||||
|
size.height = size.height.max(pos.y + sizes[&ids[i]].height);
|
||||||
|
member_offsets.insert(i, pos);
|
||||||
|
}
|
||||||
|
blocks.push(size);
|
||||||
|
}
|
||||||
|
let max_rank = ranks.iter().copied().max().unwrap_or(0);
|
||||||
|
charge(
|
||||||
|
&mut work,
|
||||||
|
links
|
||||||
|
.len()
|
||||||
|
.saturating_mul(comps.len().saturating_add(1))
|
||||||
|
.saturating_mul(comps.len().saturating_add(1)),
|
||||||
|
)?;
|
||||||
|
let mut block_positions = vec![Position { x: 0.0, y: 0.0 }; comps.len()];
|
||||||
|
let mut x = 0.0;
|
||||||
|
let mut total = Size {
|
||||||
|
width: 0.0,
|
||||||
|
height: 0.0,
|
||||||
|
};
|
||||||
|
let mut prior_order = vec![0usize; comps.len()];
|
||||||
|
for rank in 0..=max_rank {
|
||||||
|
let mut layer: Vec<_> = (0..comps.len()).filter(|&c| ranks[c] == rank).collect();
|
||||||
|
// Fixed one-pass proximity order, including undirected links, without
|
||||||
|
// inventing direction or changing condensation ranks.
|
||||||
|
layer.sort_by_key(|&c| {
|
||||||
|
let neighbors: Vec<_> = links
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&(a, b, _)| {
|
||||||
|
let (a, b) = (owner[a], owner[b]);
|
||||||
|
if a == c && ranks[b] < rank {
|
||||||
|
Some(prior_order[b])
|
||||||
|
} else if b == c && ranks[a] < rank {
|
||||||
|
Some(prior_order[a])
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(
|
||||||
|
if neighbors.is_empty() {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
neighbors.iter().sum::<usize>() / neighbors.len()
|
||||||
|
},
|
||||||
|
c,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
// Keep same-rank undirected neighbours contiguous in one canonical
|
||||||
|
// breadth-first walk; these links never become directed rank arcs.
|
||||||
|
let mut proximity = Vec::new();
|
||||||
|
let mut remaining: BTreeSet<_> = layer.iter().copied().collect();
|
||||||
|
for &start in &layer {
|
||||||
|
if !remaining.remove(&start) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut queue = std::collections::VecDeque::from([start]);
|
||||||
|
while let Some(c) = queue.pop_front() {
|
||||||
|
proximity.push(c);
|
||||||
|
for &next in &layer {
|
||||||
|
charge(&mut work, links.len() + 1)?;
|
||||||
|
if remaining.contains(&next)
|
||||||
|
&& links.iter().any(|&(a, b, _)| {
|
||||||
|
(owner[a] == c && owner[b] == next)
|
||||||
|
|| (owner[b] == c && owner[a] == next)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
remaining.remove(&next);
|
||||||
|
queue.push_back(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layer = proximity;
|
||||||
|
let mut cut = Size {
|
||||||
|
width: PAD,
|
||||||
|
height: PAD,
|
||||||
|
};
|
||||||
|
for &(a, b, label) in &links {
|
||||||
|
if owner[a] != owner[b] && (ranks[owner[a]] == rank || ranks[owner[b]] == rank) {
|
||||||
|
cut.width = cut.width.max(label.width + PAD);
|
||||||
|
cut.height = cut.height.max(label.height + PAD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut y = 0.0;
|
||||||
|
let mut width = 0.0_f32;
|
||||||
|
for (i, c) in layer.into_iter().enumerate() {
|
||||||
|
prior_order[c] = i;
|
||||||
|
block_positions[c] = Position { x, y };
|
||||||
|
y += blocks[c].height + cut.height;
|
||||||
|
width = width.max(blocks[c].width);
|
||||||
|
}
|
||||||
|
total.width = total.width.max(x + width);
|
||||||
|
total.height = total.height.max((y - cut.height).max(0.0));
|
||||||
|
x += width + cut.width;
|
||||||
|
}
|
||||||
|
let header = if let Some(id) = parent {
|
||||||
|
let n = view.nodes.iter().find(|n| n.id == id).unwrap();
|
||||||
|
if n.collapsed {
|
||||||
|
return Err(error("folded node has projected children"));
|
||||||
|
}
|
||||||
|
if d.projection.state_machine().is_some() {
|
||||||
|
projection::state_header_height(project, n.element)
|
||||||
|
} else {
|
||||||
|
64.0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
for (i, id) in ids.iter().enumerate() {
|
||||||
|
let b = block_positions[owner[i]];
|
||||||
|
let m = member_offsets[&i];
|
||||||
|
offsets.insert(
|
||||||
|
*id,
|
||||||
|
Position {
|
||||||
|
x: b.x + m.x + if parent.is_some() { PAD } else { 0.0 },
|
||||||
|
y: b.y + m.y + if parent.is_some() { header + PAD } else { 0.0 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(id) = parent {
|
||||||
|
let size = sizes.get_mut(&id).unwrap();
|
||||||
|
size.width = size.width.max(total.width + 2.0 * PAD);
|
||||||
|
size.height = size.height.max(total.height + header + 2.0 * PAD);
|
||||||
|
}
|
||||||
|
let lifted: Vec<_> = arcs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(a, targets)| targets.iter().map(move |&b| (ids[a], ids[b])))
|
||||||
|
.collect();
|
||||||
|
diagnostics.push(serde_json::json!({"visible_parent":parent,"components":component_diagnostics,"directed_lifted_edges":lifted,"undirected_lifted_pairs":undirected,"block_positions":block_positions,"bounds":total,"policy":"SCCs of sibling-subtree quotient, not global semantic SCCs. Undirected edges affect local gap and deterministic proximity only; no rank direction invented."}));
|
||||||
|
}
|
||||||
|
let mut plans = Vec::new();
|
||||||
|
for n in &view.nodes {
|
||||||
|
let mut position = offsets[&n.id];
|
||||||
|
let mut parent = n.visual_parent;
|
||||||
|
while let Some(id) = parent {
|
||||||
|
charge(&mut work, 1)?;
|
||||||
|
let p = offsets[&id];
|
||||||
|
position.x += p.x;
|
||||||
|
position.y += p.y;
|
||||||
|
parent = parents[&id];
|
||||||
|
}
|
||||||
|
position.x += 40.0;
|
||||||
|
position.y += 40.0;
|
||||||
|
let size = sizes[&n.id];
|
||||||
|
if !position.x.is_finite()
|
||||||
|
|| !position.y.is_finite()
|
||||||
|
|| position.x + size.width > SPAN
|
||||||
|
|| position.y + size.height > SPAN
|
||||||
|
{
|
||||||
|
return Err(error("extent bound exceeded"));
|
||||||
|
}
|
||||||
|
plans.push(PlacementNode {
|
||||||
|
id: n.id,
|
||||||
|
position,
|
||||||
|
size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let result = import_placement(
|
||||||
|
project,
|
||||||
|
diagram,
|
||||||
|
PlacementPlan {
|
||||||
|
schema: 1,
|
||||||
|
nodes: plans,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
let final_view = projection::project_view(&result, diagram);
|
||||||
|
charge(
|
||||||
|
&mut work,
|
||||||
|
final_view.edges.len() * (final_view.nodes.len() + 1) * 16,
|
||||||
|
)?;
|
||||||
|
if !routing::lead_incompatibilities(&result, &result.diagrams[&diagram], &final_view).is_empty()
|
||||||
|
{
|
||||||
|
return Err(error(
|
||||||
|
"final mandatory port lead incompatible; no repair attempted",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
result,
|
||||||
|
serde_json::json!({"groups":diagnostics,"work_charged":work,"work_limit":LIMIT,"scope":"Projected-forest quotient heuristic; actual final reprojection and necessary lead guard checked. No full-route or readability acceptance."}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn directed_cycles_remain_separate_across_one_way_bridge() {
|
||||||
|
let arcs = vec![
|
||||||
|
BTreeSet::from([1]),
|
||||||
|
BTreeSet::from([0, 2]),
|
||||||
|
BTreeSet::from([3]),
|
||||||
|
BTreeSet::from([2]),
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
components(&arcs, &mut 0).unwrap(),
|
||||||
|
vec![vec![0, 1], vec![2, 3]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn perimeter_places_ten_unique_cells_without_overlap() {
|
||||||
|
let (positions, _, _) = perimeter(
|
||||||
|
10,
|
||||||
|
Size {
|
||||||
|
width: 224.0,
|
||||||
|
height: 132.0,
|
||||||
|
},
|
||||||
|
Size {
|
||||||
|
width: 32.0,
|
||||||
|
height: 32.0,
|
||||||
|
},
|
||||||
|
&mut 0,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(positions.len(), 10);
|
||||||
|
for (i, a) in positions.iter().enumerate() {
|
||||||
|
for b in &positions[i + 1..] {
|
||||||
|
assert!((a.x - b.x).abs() >= 256.0 || (a.y - b.y).abs() >= 164.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Coherent cyclic-group placement experiment
|
||||||
|
|
||||||
|
Source evidence atae9dad54 and the independent grouped-corridor audit change the
|
||||||
|
next action. The old sparse candidate has four10-node strongly connected
|
||||||
|
components, but its alias cycle breaking interleaves them across16 ranks. The
|
||||||
|
single top corridor contributes38880/40522 pairwise shared units. Mixed has the
|
||||||
|
same defect:4006/4228 at the top boundary. Test coherent compact SCC blocks with
|
||||||
|
the released router before introducing a joint route-separation algorithm.
|
||||||
|
This is a structural candidate, not another penalty or gap-repair sweep.
|
||||||
|
|
||||||
|
## Fixed candidate
|
||||||
|
|
||||||
|
- Plan each sibling group of the actual projected visual forest. Lift oriented
|
||||||
|
connections to immediate child-subtree representatives, compute SCCs, then
|
||||||
|
rank their condensation DAG. Never break a cycle by choosing an arbitrary
|
||||||
|
vertex in another still-blocked component. Undirected edges impose no rank
|
||||||
|
direction. Preserve all semantic orientation/endpoints and authored hierarchy.
|
||||||
|
- Pack a multi-member SCC around a compact rectangular perimeter, using a
|
||||||
|
deterministic directed DFS successor order with alias/placement ties. Among
|
||||||
|
integer perimeter grids, prefer near-square physical aspect≤2 when feasible,
|
||||||
|
then minimum area; otherwise minimum aspect, with deterministic ties. This
|
||||||
|
rule is frozen before execution, not selected from measured corpus scores.
|
||||||
|
- Use local edge-label estimates for gaps inside and between blocks. Minimum
|
||||||
|
gaps and child-header clearance reserve at least the18+12 mandatory-lead
|
||||||
|
requirement. Recompute parent enclosure from frozen minima. Core estimates
|
||||||
|
are not native glyph measurements. No global longest label inflates every gap.
|
||||||
|
- Folded visible nodes are opaque with their exact compact projected footprint;
|
||||||
|
stored expanded sizes and latent descendants follow the audited original-to-
|
||||||
|
final import translation policy. Other omitted placements remain unchanged.
|
||||||
|
Final import alone reconstructs eligible route reset and retained translations.
|
||||||
|
- Check actual final projection and mandatory leads. Geometry mismatch, missing
|
||||||
|
owners, bounds/work exhaustion and infeasible leads remain explicit candidate
|
||||||
|
errors; no implicit repair loop or changed port anchors. Scenario remains
|
||||||
|
outside this Arrange operation. Nodes/edges/work and grid enumeration are
|
||||||
|
bounded and candidate failure leaves the original unchanged.
|
||||||
|
|
||||||
|
At a nested level these SCCs belong to the child-subtree quotient graph; they
|
||||||
|
are a placement heuristic, not a claim about original leaf semantic SCCs.
|
||||||
|
Cross-boundary edges and per-parent component/rank membership are reported.
|
||||||
|
Nested grouping must not rewrite user containment or silently flatten it.
|
||||||
|
|
||||||
|
## Evaluation and stop
|
||||||
|
|
||||||
|
Use the same five frozen independent cases and the original nine small fixtures.
|
||||||
|
Retain original/current Arrange-reset/old native results; add one SCC candidate,
|
||||||
|
without parameter sweeps or changing the router. Report unsupported/error cases.
|
||||||
|
Check complete Project conservation with only declared geometry/reset/retained
|
||||||
|
translation exceptions, exact port-anchor and hidden-subtree behavior, final
|
||||||
|
header/enclosure and node/route collisions, warnings, proper/T/bend contacts,
|
||||||
|
shared intervals and pair multiplicities, labels, area and length. Record
|
||||||
|
repeat-input equality and ten-call stability, including folded/compound cases.
|
||||||
|
The two relation-ID variants must expose any stable-ID routing sensitivity.
|
||||||
|
|
||||||
|
The structural witness is actual condensation order with compact component
|
||||||
|
members and reduced dominant shared top corridors. A lower shared total alone
|
||||||
|
is insufficient: preserve the full quality vector and identify any newly
|
||||||
|
ambiguous pair, port failure, label interference or disproportionate area cost.
|
||||||
|
Model/identity/history preservation is not evidence of readability. Do not
|
||||||
|
adopt after compilation or metric checks alone; a viable candidate still needs
|
||||||
|
production command/API/history integration and native route-tracing workflows.
|
||||||
|
|
||||||
|
Stop after this one structurally distinct candidate and independent review. If
|
||||||
|
coherent blocks only move sharing elsewhere or create hard failures, retain
|
||||||
|
the counterexample. Joint derived route separation and virtual long-edge
|
||||||
|
ordering remain distinct alternatives; choose the next action from the observed
|
||||||
|
failure, not from commitment to this prototype.
|
||||||
|
|
||||||
|
Windows only; Linux builds and the frozen published prerelease remain unchanged.
|
||||||
Reference in New Issue
Block a user