experiment(layout): keep cyclic quotient groups compact in condensation order

This commit is contained in:
2026-09-24 21:09:04 -07:00
parent ae9dad54b7
commit 5300a8c803
3 changed files with 687 additions and 1 deletions
+68 -1
View File
@@ -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);
}
}
}
}