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};
|
||||
#[path = "auto_layout_probe/clearance.rs"]
|
||||
mod clearance;
|
||||
#[path = "auto_layout_probe/condensed.rs"]
|
||||
mod condensed;
|
||||
#[path = "auto_layout_probe/layered.rs"]
|
||||
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) {
|
||||
let manifest: Value = serde_json::from_slice(&std::fs::read(path).expect("Read manifest"))
|
||||
.expect("Decode manifest");
|
||||
@@ -1201,10 +1258,20 @@ fn main() {
|
||||
repair_placement_cli(std::path::Path::new(&args[2]), &args[3]);
|
||||
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!(
|
||||
args.len(),
|
||||
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!(
|
||||
"{}",
|
||||
|
||||
@@ -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