diff --git a/crates/oa-core/examples/auto_layout_probe.rs b/crates/oa-core/examples/auto_layout_probe.rs index 196691d6..2b18cf12 100644 --- a/crates/oa-core/examples/auto_layout_probe.rs +++ b/crates/oa-core/examples/auto_layout_probe.rs @@ -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!( "{}", diff --git a/crates/oa-core/examples/auto_layout_probe/condensed.rs b/crates/oa-core/examples/auto_layout_probe/condensed.rs new file mode 100644 index 00000000..205c95c2 --- /dev/null +++ b/crates/oa-core/examples/auto_layout_probe/condensed.rs @@ -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], work: &mut usize) -> Result>, 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, 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, + parents: &BTreeMap>, + work: &mut usize, +) -> Result, 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 { + 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, Vec> = 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::>(),"walk":order.iter().map(|&i|ids[i]).collect::>(),"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::() / 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); + } + } + } +} diff --git a/harness/experiments/scc-placement-20260925/plan.md b/harness/experiments/scc-placement-20260925/plan.md new file mode 100644 index 00000000..8320b2b3 --- /dev/null +++ b/harness/experiments/scc-placement-20260925/plan.md @@ -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.