diff --git a/crates/oa-core/examples/auto_layout_probe.rs b/crates/oa-core/examples/auto_layout_probe.rs index 04a7ad80..25486fd5 100644 --- a/crates/oa-core/examples/auto_layout_probe.rs +++ b/crates/oa-core/examples/auto_layout_probe.rs @@ -5,6 +5,8 @@ use oa_core::{ }; use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; +#[path = "auto_layout_probe/boundary_fixtures.rs"] +mod boundary_fixtures; #[path = "auto_layout_probe/clearance.rs"] mod clearance; #[path = "auto_layout_probe/condensed.rs"] @@ -411,6 +413,7 @@ fn metrics(project: &Project, diagram: Id) -> Value { }) } +#[derive(Clone)] struct Builder { project: Project, diagram: Id, @@ -1252,17 +1255,35 @@ fn repair_placement_cli(project_file: &std::path::Path, diagram_text: &std::ffi: } fn scc_observe(name: &str, f: Builder) -> Value { + placement_observe(name, f, false) +} + +fn placement_observe(name: &str, f: Builder, boundary_order: bool) -> 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) { + let with_diagnostics = |p: &Project| { + if boundary_order { + condensed::boundary_candidate_with_diagnostics(p, diagram) + } else { + condensed::candidate_with_diagnostics(p, diagram) + } + }; + let propose = |p: &Project| { + if boundary_order { + condensed::boundary_candidate_with_diagnostics(p, diagram).map(|(p, _)| p) + } else { + condensed::candidate(p, diagram) + } + }; + let candidate = match with_diagnostics(&before) { Err(error) => json!({"supported":false,"error":error.to_string()}), Ok((after, plan)) => { - let same = condensed::candidate(&before, diagram); + let same = propose(&before); let mut repeated = after.clone(); let mut stability = Vec::new(); for pass in 2..=10 { - match condensed::candidate(&repeated, diagram) { + match propose(&repeated) { Ok(next) => { stability.push(json!({"pass":pass,"equal_previous":next==repeated,"equal_first":next==after})); repeated = next; @@ -1283,7 +1304,11 @@ fn scc_observe(name: &str, f: Builder) -> Value { "before":metrics(&before,diagram),"candidate":candidate}) } -fn scc_placement_cli(project_file: &std::path::Path, diagram_text: &std::ffi::OsStr) { +fn scc_placement_cli( + project_file: &std::path::Path, + diagram_text: &std::ffi::OsStr, + boundary_order: bool, +) { let project: Project = serde_json::from_slice(&std::fs::read(project_file).expect("Read Project")) .expect("Decode Project"); @@ -1294,14 +1319,15 @@ fn scc_placement_cli(project_file: &std::path::Path, diagram_text: &std::ffi::Os .expect("Diagram UUID"); println!( "{}", - scc_observe( + placement_observe( "external_case", Builder { project, diagram, next: 0, witness: None - } + }, + boundary_order, ) ); } @@ -1416,7 +1442,11 @@ fn main() { return; } if args.len() == 4 && args[1] == "--scc-placement" { - scc_placement_cli(std::path::Path::new(&args[2]), &args[3]); + scc_placement_cli(std::path::Path::new(&args[2]), &args[3], false); + return; + } + if args.len() == 4 && args[1] == "--boundary-placement" { + scc_placement_cli(std::path::Path::new(&args[2]), &args[3], true); return; } if args.len() == 2 && args[1] == "--scc-fixtures" { @@ -1425,10 +1455,27 @@ fn main() { } return; } + if args.len() == 2 && args[1] == "--boundary-fixtures" { + for (name, f) in fixtures() { + println!("{}", placement_observe(name, f, true)); + } + return; + } + if args.len() == 2 && args[1] == "--boundary-extra-fixtures" { + for (name, f) in boundary_fixtures::fixtures() { + println!( + "{}", + json!({"fixture":name, + "baseline":scc_observe(name, f.clone()), + "boundary":placement_observe(name, f, true)}) + ); + } + 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 | --scc-placement PROJECT UUID | --scc-fixtures]" + "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 | --boundary-placement PROJECT UUID | --boundary-fixtures | --boundary-extra-fixtures]" ); println!( "{}", diff --git a/crates/oa-core/examples/auto_layout_probe/boundary_fixtures.rs b/crates/oa-core/examples/auto_layout_probe/boundary_fixtures.rs new file mode 100644 index 00000000..e5604187 --- /dev/null +++ b/crates/oa-core/examples/auto_layout_probe/boundary_fixtures.rs @@ -0,0 +1,131 @@ +//! Independent structural falsifiers for incoming boundary ordering. +use super::*; + +fn pair(f: &mut Builder, name: &str, x: f32) -> (Id, Id, Id) { + let parent = f.function(name, None, x, 40.0); + let a = f.function(&format!("{name} A"), Some(parent), x + 60.0, 130.0); + let b = f.function(&format!("{name} B"), Some(parent), x + 60.0, 330.0); + (parent, a, b) +} + +pub(super) fn fixtures() -> Vec<(&'static str, Builder)> { + let mut all = Vec::new(); + let mut f = Builder::new("boundary_opposing_parents"); + let (_, la, lb) = pair(&mut f, "Left", 40.0); + let (_, ra, rb) = pair(&mut f, "Right", 820.0); + for (name, a, b) in [ + ("Reverse upper", la, rb), + ("Reverse lower", lb, ra), + ("Return upper", ra, la), + ("Return lower", rb, lb), + ] { + f.exchange(name, a, b); + } + all.push(("boundary_opposing_parents", f)); + + let mut f = Builder::new("boundary_three_parent_chain"); + let (_, la, lb) = pair(&mut f, "Left", 40.0); + let (middle, ma, mb) = pair(&mut f, "Middle", 820.0); + let (_, ra, rb) = pair(&mut f, "Right", 1600.0); + for (name, a, b) in [ + ("Left upper", la, mb), + ("Left lower", lb, ma), + ("Middle upper", mb, ra), + ("Middle lower", ma, rb), + ] { + f.exchange(name, a, b); + } + let mut translated = f.clone(); + for element in [middle, ma, mb] { + let node = translated.node(element); + let position = &mut translated + .project + .diagrams + .get_mut(&translated.diagram) + .unwrap() + .nodes + .get_mut(&node) + .unwrap() + .position; + position.x += 300.0; + position.y += 500.0; + } + let mut permuted = f.clone(); + let ids: Vec<_> = permuted.project.relations.keys().copied().collect(); + for (i, id) in ids.iter().enumerate() { + reidentify( + &mut permuted.project, + *id, + Id::from_u128(1_000_000 + i as u128), + ) + .unwrap(); + } + for (i, id) in ids.iter().rev().enumerate() { + reidentify( + &mut permuted.project, + Id::from_u128(1_000_000 + i as u128), + *id, + ) + .unwrap(); + } + all.push(("boundary_three_parent_chain", f)); + all.push(("boundary_three_parent_translated", translated)); + all.push(("boundary_three_parent_edge_ids_reversed", permuted)); + + let mut f = Builder::new("boundary_two_local_sccs"); + let (_, sa, sb) = pair(&mut f, "Source", 40.0); + let target = f.function("Target", None, 820.0, 40.0); + let x1 = f.function("X1", Some(target), 880.0, 130.0); + let x2 = f.function("X2", Some(target), 1200.0, 130.0); + let y1 = f.function("Y1", Some(target), 880.0, 330.0); + let y2 = f.function("Y2", Some(target), 1200.0, 330.0); + let z = f.function("Z", Some(target), 1520.0, 230.0); + for (name, a, b) in [ + ("X forward", x1, x2), + ("X back", x2, x1), + ("Y forward", y1, y2), + ("Y back", y2, y1), + ("X result", x2, z), + ("Y result", y2, z), + ("Upper boundary", sa, y1), + ("Lower boundary", sb, x1), + ] { + f.exchange(name, a, b); + } + all.push(("boundary_two_local_sccs", f)); + + let mut f = super::fixtures() + .into_iter() + .find(|(name, _)| *name == "two_containers_reversed_children") + .unwrap() + .1; + let relations: Vec<_> = f.project.relations.values().cloned().collect(); + for relation in relations { + if let RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } = relation.body + { + for (port, side, offset) in [ + (source_port, PortAnchorSide::Right, 0.3), + (target_port, PortAnchorSide::Left, 0.7), + ] { + let owner = f.project.elements[&port].parent.unwrap(); + let node = f.node(owner); + f.project = prepare_command( + &f.project, + &Command::SetPortAnchor { + diagram: f.diagram, + node, + anchor: PortAnchor { port, side, offset }, + }, + ) + .unwrap() + .project; + } + } + } + all.push(("boundary_two_containers_anchored", f)); + all +} diff --git a/crates/oa-core/examples/auto_layout_probe/condensed.rs b/crates/oa-core/examples/auto_layout_probe/condensed.rs index 4afc4131..c6af2a53 100644 --- a/crates/oa-core/examples/auto_layout_probe/condensed.rs +++ b/crates/oa-core/examples/auto_layout_probe/condensed.rs @@ -7,6 +7,9 @@ const LIMIT: usize = 20_000_000; const PAD: f32 = 32.0; const SPAN: f32 = 100_000.0; +#[path = "condensed/boundary.rs"] +mod boundary; + fn error(text: &str) -> ModelError { ModelError(format!("SCC placement: {text}")) } @@ -161,6 +164,21 @@ pub fn candidate(project: &Project, diagram: Id) -> Result pub fn candidate_with_diagnostics( project: &Project, diagram: Id, +) -> Result<(Project, serde_json::Value), ModelError> { + candidate_policy(project, diagram, false) +} + +pub fn boundary_candidate_with_diagnostics( + project: &Project, + diagram: Id, +) -> Result<(Project, serde_json::Value), ModelError> { + candidate_policy(project, diagram, true) +} + +fn candidate_policy( + project: &Project, + diagram: Id, + order_boundaries: bool, ) -> Result<(Project, serde_json::Value), ModelError> { let d = project .diagrams @@ -217,6 +235,7 @@ pub fn candidate_with_diagnostics( 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 boundary_groups = 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 { @@ -361,6 +380,7 @@ pub fn candidate_with_diagnostics( height: 0.0, }; let mut prior_order = vec![0usize; comps.len()]; + let mut bands = Vec::new(); 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 @@ -426,6 +446,12 @@ pub fn candidate_with_diagnostics( } let mut y = 0.0; let mut width = 0.0_f32; + if order_boundaries && parent.is_some() { + bands.push(boundary::Band { + components: layer.clone(), + gap: cut.height, + }); + } for (i, c) in layer.into_iter().enumerate() { prior_order[c] = i; block_positions[c] = Position { x, y }; @@ -464,6 +490,25 @@ pub fn candidate_with_diagnostics( 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); + if order_boundaries { + boundary_groups.push(boundary::Group { + parent: id, + blocks: comps + .iter() + .enumerate() + .map(|(c, members)| boundary::Block { + members: members.iter().map(|&i| ids[i]).collect(), + size: blocks[c], + position: block_positions[c], + }) + .collect(), + bands, + undirected: undirected + .iter() + .map(|(a, b)| (owner[index[a]], owner[index[b]])) + .collect(), + }); + } } let lifted: Vec<_> = arcs .iter() @@ -472,6 +517,27 @@ pub fn candidate_with_diagnostics( .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 boundary_report = if order_boundaries { + let report = boundary::reorder( + &mut boundary_groups, + &parents, + &mut offsets, + &sizes, + &view.edges, + &mut work, + )?; + for group in &boundary_groups { + let diagnostic = diagnostics + .iter_mut() + .find(|entry| entry["visible_parent"] == serde_json::json!(group.parent)) + .unwrap(); + diagnostic["block_positions"] = + serde_json::json!(group.blocks.iter().map(|b| b.position).collect::>()); + } + Some(report) + } else { + None + }; let mut plans = Vec::new(); for n in &view.nodes { let mut position = offsets[&n.id]; @@ -518,10 +584,11 @@ pub fn candidate_with_diagnostics( "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."}), - )) + let mut report = 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."}); + if let Some(boundary_report) = boundary_report { + report["boundary_order"] = boundary_report; + } + Ok((result, report)) } #[cfg(test)] diff --git a/crates/oa-core/examples/auto_layout_probe/condensed/boundary.rs b/crates/oa-core/examples/auto_layout_probe/condensed/boundary.rs new file mode 100644 index 00000000..6952bed2 --- /dev/null +++ b/crates/oa-core/examples/auto_layout_probe/condensed/boundary.rs @@ -0,0 +1,672 @@ +//! One deterministic boundary-order pass; dependency cycles use frozen scores. +use super::{charge, components, error, lift}; +use oa_core::{Id, ModelError, Position, Size, projection::ProjectedEdge}; +use std::collections::{BTreeMap, BTreeSet}; + +pub(super) struct Block { + pub members: Vec, + pub size: Size, + pub position: Position, +} +pub(super) struct Band { + pub components: Vec, + pub gap: f32, +} +pub(super) struct Group { + pub parent: Id, + pub blocks: Vec, + pub bands: Vec, + pub undirected: BTreeSet<(usize, usize)>, +} + +#[derive(Clone)] +struct Unit { + blocks: Vec, + sources: BTreeSet, + hints: Vec<(Id, Id, Id)>, +} + +fn center( + id: Id, + parents: &BTreeMap>, + offsets: &BTreeMap, + sizes: &BTreeMap, + work: &mut usize, +) -> Result { + let mut at = Some(id); + let mut seen = BTreeSet::new(); + let mut y = 0.0_f32; + while let Some(node) = at { + charge(work, 1)?; + if !seen.insert(node) { + return Err(error("boundary cyclic ancestry")); + } + let pos = offsets + .get(&node) + .ok_or_else(|| error("boundary missing offset"))?; + if !pos.x.is_finite() || !pos.y.is_finite() { + return Err(error("boundary nonfinite offset")); + } + if seen.len() == 1 { + y = pos.y; + } else { + y += pos.y; + } + if !y.is_finite() { + return Err(error("boundary composed position overflow")); + } + at = *parents + .get(&node) + .ok_or_else(|| error("boundary missing parent"))?; + } + let size = sizes + .get(&id) + .ok_or_else(|| error("boundary missing source size"))?; + y += 40.0; + let center = f64::from(y) + f64::from(size.height) / 2.0; + if !center.is_finite() { + return Err(error("boundary nonfinite center")); + } + Ok(center) +} + +fn translate(value: f32, delta: f32) -> Result { + let next = value + delta; + if !next.is_finite() || ((f64::from(next) - f64::from(value)) - f64::from(delta)).abs() > 0.001 + { + return Err(error("boundary member translation precision exceeded")); + } + Ok(next) +} + +fn score( + unit: &Unit, + parents: &BTreeMap>, + offsets: &BTreeMap, + sizes: &BTreeMap, + work: &mut usize, +) -> Result, ModelError> { + if unit.sources.is_empty() { + return Ok(None); + } + let mut total = 0.0; + for &source in &unit.sources { + total += center(source, parents, offsets, sizes, work)?; + } + let value = total / unit.sources.len() as f64; + if !value.is_finite() { + return Err(error("boundary nonfinite mean")); + } + Ok(Some(value)) +} + +fn ordered_units(scores: &[Option]) -> Vec { + let mut anchored: Vec<_> = (0..scores.len()).filter(|&i| scores[i].is_some()).collect(); + anchored.sort_by(|&a, &b| { + scores[a] + .unwrap() + .total_cmp(&scores[b].unwrap()) + .then(a.cmp(&b)) + }); + let mut next = anchored.into_iter(); + (0..scores.len()) + .map(|i| { + if scores[i].is_some() { + next.next().unwrap() + } else { + i + } + }) + .collect() +} + +fn packing( + blocks: &[Block], + before: &[usize], + after: &[usize], + gap: f32, +) -> Result, ModelError> { + const EPS: f64 = 0.001; + let origin = f64::from(blocks[before[0]].position.y); + let mut canonical = origin; + for &c in before { + if (f64::from(blocks[c].position.y) - canonical).abs() > EPS { + return Err(error("boundary non-prefix canonical band")); + } + canonical += f64::from(blocks[c].size.height) + f64::from(gap); + } + let mut next = origin; + let mut ys = Vec::new(); + for &c in after { + let y = next as f32; + if !y.is_finite() || (f64::from(y) - next).abs() > EPS { + return Err(error("boundary prefix precision bound exceeded")); + } + ys.push(y); + next += f64::from(blocks[c].size.height) + f64::from(gap); + } + if (next - canonical).abs() > EPS { + return Err(error("boundary envelope changed")); + } + if before == after { + Ok(before.iter().map(|&c| blocks[c].position.y).collect()) + } else { + Ok(ys) + } +} + +pub(super) fn reorder( + groups: &mut [Group], + parents: &BTreeMap>, + offsets: &mut BTreeMap, + sizes: &BTreeMap, + edges: &[ProjectedEdge], + work: &mut usize, +) -> Result { + let mut group_order: Vec<_> = (0..groups.len()).collect(); + group_order.sort_by_key(|&i| groups[i].parent); + let mut offset_owner = BTreeMap::new(); + let mut parent_seen = BTreeSet::new(); + let mut units: Vec>> = vec![Vec::new(); groups.len()]; + let mut active = BTreeSet::new(); + for &g in &group_order { + let group = &groups[g]; + if !parent_seen.insert(group.parent) || !parents.contains_key(&group.parent) { + return Err(error("boundary duplicate/missing group parent")); + } + let mut child_block = BTreeMap::new(); + for (c, block) in group.blocks.iter().enumerate() { + if !block.size.width.is_finite() + || !block.size.height.is_finite() + || block.size.width <= 0.0 + || block.size.height <= 0.0 + || !block.position.x.is_finite() + || !block.position.y.is_finite() + { + return Err(error("boundary invalid block geometry")); + } + for &id in &block.members { + if parents.get(&id) != Some(&Some(group.parent)) + || !offsets.contains_key(&id) + || child_block.insert(id, c).is_some() + || offset_owner.insert(id, g).is_some() + { + return Err(error("boundary invalid child membership")); + } + } + } + let mut covered = BTreeSet::new(); + for band in &group.bands { + if !band.gap.is_finite() || band.gap < 0.0 || band.components.is_empty() { + return Err(error("boundary invalid band")); + } + let local: BTreeMap<_, _> = band + .components + .iter() + .enumerate() + .map(|(i, &c)| (c, i)) + .collect(); + for &c in &band.components { + if c >= group.blocks.len() || !covered.insert(c) { + return Err(error("boundary duplicate/unknown band block")); + } + } + let mut arcs = vec![BTreeSet::new(); band.components.len()]; + for &(a, b) in &group.undirected { + charge(work, 1)?; + if let (Some(&a), Some(&b)) = (local.get(&a), local.get(&b)) { + arcs[a].insert(b); + arcs[b].insert(a); + } + } + let bundles = components(&arcs, work)?; + let mut band_units = Vec::new(); + for bundle in bundles { + if bundle.windows(2).any(|v| v[1] != v[0] + 1) { + return Err(error("boundary undirected bundle not contiguous")); + } + let blocks: Vec<_> = bundle.iter().map(|&i| band.components[i]).collect(); + let mut unit = Unit { + blocks, + sources: BTreeSet::new(), + hints: Vec::new(), + }; + for edge in edges { + charge(work, 1)?; + if !edge.oriented { + continue; + } + let target = lift(edge.target, Some(group.parent), parents, work)?; + let Some(target) = target else { + continue; + }; + if !unit.blocks.contains( + child_block + .get(&target) + .ok_or_else(|| error("boundary missing lifted target"))?, + ) { + continue; + } + if lift(edge.source, Some(group.parent), parents, work)?.is_none() { + unit.sources.insert(edge.source); + unit.hints.push((edge.id, edge.source, edge.target)); + } + } + unit.hints.sort(); + band_units.push(unit); + } + if band_units.iter().filter(|u| !u.sources.is_empty()).count() >= 2 { + active.insert(g); + } + units[g].push(band_units); + } + if covered.len() != group.blocks.len() { + return Err(error("boundary incomplete band inventory")); + } + } + let active_order: Vec<_> = group_order + .into_iter() + .filter(|g| active.contains(g)) + .collect(); + let active_index: BTreeMap<_, _> = active_order + .iter() + .enumerate() + .map(|(i, &g)| (g, i)) + .collect(); + let mut dependencies = vec![BTreeSet::new(); active_order.len()]; + let mut dependency_evidence = BTreeSet::new(); + for (&g, &gi) in &active_index { + for unit in units[g].iter().flatten() { + for &source in &unit.sources { + let mut at = Some(source); + let mut seen = BTreeSet::new(); + while let Some(node) = at { + charge(work, 1)?; + if !seen.insert(node) { + return Err(error("boundary dependency ancestry cycle")); + } + if let Some(&owner) = offset_owner.get(&node) + && let Some(&oi) = active_index.get(&owner) + { + dependencies[oi].insert(gi); + dependency_evidence.insert(( + groups[owner].parent, + groups[g].parent, + source, + node, + )); + } + at = *parents + .get(&node) + .ok_or_else(|| error("boundary missing dependency ancestor"))?; + } + } + } + } + let sccs = components(&dependencies, work)?; + let mut scc_owner = vec![0; active_order.len()]; + for (s, members) in sccs.iter().enumerate() { + for &i in members { + scc_owner[i] = s; + } + } + let mut dag = vec![BTreeSet::new(); sccs.len()]; + for (a, targets) in dependencies.iter().enumerate() { + for &b in targets { + if scc_owner[a] != scc_owner[b] { + dag[scc_owner[a]].insert(scc_owner[b]); + } + } + } + let mut done = BTreeSet::new(); + let mut records = Vec::new(); + let mut processed = Vec::new(); + while done.len() < sccs.len() { + charge(work, sccs.len() * sccs.len())?; + let s = (0..sccs.len()) + .find(|s| { + !done.contains(s) + && (0..sccs.len()).all(|p| !dag[p].contains(s) || done.contains(&p)) + }) + .ok_or_else(|| error("boundary condensation cycle"))?; + // Score the entire dependency SCC before modifying any offsets. + let mut snapshots = Vec::new(); + for &i in &sccs[s] { + let g = active_order[i]; + for (b, band_units) in units[g].iter().enumerate() { + let scores = band_units + .iter() + .map(|u| score(u, parents, offsets, sizes, work)) + .collect::, _>>()?; + snapshots.push((g, b, scores)); + } + } + for (g, b, scores) in snapshots { + let band_units = &units[g][b]; + let order = ordered_units(&scores); + let before = groups[g].bands[b].components.clone(); + let gap = groups[g].bands[b].gap; + let after: Vec<_> = order + .iter() + .flat_map(|&u| band_units[u].blocks.iter().copied()) + .collect(); + let ys = packing(&groups[g].blocks, &before, &after, gap)?; + let mut changed = Vec::new(); + for (&c, y) in after.iter().zip(ys) { + charge(work, groups[g].blocks[c].members.len() + 1)?; + let old = groups[g].blocks[c].position.y; + let delta = y - old; + if !y.is_finite() || !delta.is_finite() { + return Err(error("boundary packing overflow")); + } + if delta != 0.0 { + changed.push(c); + } + for id in &groups[g].blocks[c].members { + let p = offsets + .get_mut(id) + .ok_or_else(|| error("boundary lost member offset"))?; + p.y = translate(p.y, delta)?; + } + groups[g].blocks[c].position.y = y; + } + groups[g].bands[b].components = after.clone(); + let changed_units: Vec<_> = band_units + .iter() + .enumerate() + .filter(|(_, u)| u.blocks.iter().any(|c| changed.contains(c))) + .map(|(i, _)| i) + .collect(); + records.push(serde_json::json!({"parent":groups[g].parent,"band":b,"before_blocks":before,"after_blocks":after,"unit_order":order,"scores":scores,"changed_blocks":changed,"changed_units":changed_units,"envelope_epsilon":0.001,"units":band_units.iter().map(|u|serde_json::json!({"blocks":u.blocks,"source_owners":u.sources,"edge_hints":u.hints})).collect::>()})); + } + processed.push(serde_json::json!({"parents":sccs[s].iter().map(|&i|groups[active_order[i]].parent).collect::>(),"cyclic":sccs[s].len()>1||sccs[s].iter().any(|&i|dependencies[i].contains(&i)),"policy":"all scores snapshotted before one simultaneous group-permutation pass"})); + done.insert(s); + } + Ok( + serde_json::json!({"active_parents":active_order.iter().map(|&g|groups[g].parent).collect::>(),"dependencies":dependency_evidence,"dependency_components":processed,"bands":records,"limitation":"One pass; cyclic dependencies use a common snapshot, not a fixed-point solution. Root unchanged; no quality or repeat-stability acceptance."}), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + fn id(n: u128) -> Id { + Id::from_u128(n) + } + fn block(n: u128, y: f32, height: f32) -> Block { + Block { + members: vec![id(n)], + size: Size { + width: 10.0, + height, + }, + position: Position { x: 0.0, y }, + } + } + fn edge(n: u128, source: u128, target: u128) -> ProjectedEdge { + ProjectedEdge { + id: id(n), + source: id(source), + target: id(target), + trace: false, + oriented: true, + source_port: None, + target_port: None, + } + } + #[test] + fn anchored_sort_preserves_unanchored_ordinal_slot() { + assert_eq!( + ordered_units(&[Some(90.0), None, Some(10.0), Some(10.0)]), + vec![2, 1, 3, 0] + ); + } + #[test] + fn heterogeneous_blocks_use_new_prefix_not_old_slots() { + let blocks = vec![ + Block { + members: vec![], + size: Size { + width: 10.0, + height: 100.0, + }, + position: Position { x: 0.0, y: 0.0 }, + }, + Block { + members: vec![], + size: Size { + width: 10.0, + height: 20.0, + }, + position: Position { x: 0.0, y: 132.0 }, + }, + ]; + assert_eq!( + packing(&blocks, &[0, 1], &[1, 0], 32.0).unwrap(), + vec![0.0, 52.0] + ); + } + #[test] + fn center_includes_grandparent_offset_change() { + let (a, b, c) = (Id::from_u128(1), Id::from_u128(2), Id::from_u128(3)); + let parents = BTreeMap::from([(a, None), (b, Some(a)), (c, Some(b))]); + let mut offsets = BTreeMap::from([ + (a, Position { x: 0.0, y: 100.0 }), + (b, Position { x: 0.0, y: 20.0 }), + (c, Position { x: 0.0, y: 5.0 }), + ]); + let sizes = BTreeMap::from([( + c, + Size { + width: 10.0, + height: 10.0, + }, + )]); + assert_eq!( + center(c, &parents, &offsets, &sizes, &mut 0).unwrap(), + 170.0 + ); + offsets.get_mut(&a).unwrap().y += 300.0; + assert_eq!( + center(c, &parents, &offsets, &sizes, &mut 0).unwrap(), + 470.0 + ); + } + #[test] + fn ancestor_dependency_updates_downstream_scores_before_its_permutation() { + // Downstream parent1 sorts before upstream20, so an absent ancestor + // dependency would leave downstream order unchanged and fail this test. + let mut groups = vec![ + Group { + parent: id(1), + blocks: vec![block(2, 0.0, 100.0), block(3, 132.0, 100.0)], + bands: vec![Band { + components: vec![0, 1], + gap: 32.0, + }], + undirected: BTreeSet::new(), + }, + Group { + parent: id(20), + blocks: vec![block(21, 0.0, 100.0), block(22, 132.0, 100.0)], + bands: vec![Band { + components: vec![0, 1], + gap: 32.0, + }], + undirected: BTreeSet::new(), + }, + ]; + let parents = BTreeMap::from([ + (id(1), None), + (id(2), Some(id(1))), + (id(3), Some(id(1))), + (id(20), None), + (id(21), Some(id(20))), + (id(22), Some(id(20))), + (id(23), Some(id(21))), + (id(30), None), + (id(31), None), + (id(32), None), + ]); + let mut offsets: BTreeMap<_, _> = [ + (1, 0.0), + (2, 0.0), + (3, 132.0), + (20, 0.0), + (21, 0.0), + (22, 132.0), + (23, 10.0), + (30, 300.0), + (31, 0.0), + (32, 50.0), + ] + .into_iter() + .map(|(n, y)| (id(n), Position { x: 0.0, y })) + .collect(); + let sizes = parents + .keys() + .map(|&n| { + ( + n, + Size { + width: 10.0, + height: 10.0, + }, + ) + }) + .collect(); + let edges = vec![ + edge(101, 30, 21), + edge(102, 31, 22), + edge(103, 23, 2), + edge(104, 32, 3), + ]; + let report = reorder(&mut groups, &parents, &mut offsets, &sizes, &edges, &mut 0).unwrap(); + assert_eq!(groups[1].bands[0].components, vec![1, 0]); + assert_eq!(groups[0].bands[0].components, vec![1, 0]); + assert_eq!(offsets[&id(21)].y, 132.0); + assert_eq!(offsets[&id(23)].y, 10.0); // descendant is not directly moved + assert_eq!( + center(id(23), &parents, &offsets, &sizes, &mut 0).unwrap(), + 187.0 + ); + assert_eq!( + report["dependency_components"][0]["parents"][0], + serde_json::json!(id(20)) + ); + } + #[test] + fn undirected_bundle_stays_whole_around_unanchored_unit() { + let mut groups = vec![Group { + parent: id(100), + blocks: vec![ + block(101, 0.0, 20.0), + block(102, 30.0, 30.0), + block(103, 70.0, 40.0), + block(104, 120.0, 10.0), + ], + bands: vec![Band { + components: vec![0, 1, 2, 3], + gap: 10.0, + }], + undirected: BTreeSet::from([(0, 1)]), + }]; + let parents = BTreeMap::from([ + (id(100), None), + (id(101), Some(id(100))), + (id(102), Some(id(100))), + (id(103), Some(id(100))), + (id(104), Some(id(100))), + (id(110), None), + (id(111), None), + ]); + let mut offsets: BTreeMap<_, _> = [ + (100, 0.0), + (101, 0.0), + (102, 30.0), + (103, 70.0), + (104, 120.0), + (110, 300.0), + (111, 0.0), + ] + .into_iter() + .map(|(n, y)| (id(n), Position { x: 0.0, y })) + .collect(); + let sizes = parents + .keys() + .map(|&n| { + ( + n, + Size { + width: 10.0, + height: 10.0, + }, + ) + }) + .collect(); + let report = reorder( + &mut groups, + &parents, + &mut offsets, + &sizes, + &[ + edge(201, 110, 101), + edge(202, 110, 102), + edge(203, 111, 104), + ], + &mut 0, + ) + .unwrap(); + assert_eq!(groups[0].bands[0].components, vec![3, 2, 0, 1]); + assert_eq!( + [ + offsets[&id(104)].y, + offsets[&id(103)].y, + offsets[&id(101)].y, + offsets[&id(102)].y + ], + [0.0, 20.0, 70.0, 100.0] + ); + assert_eq!( + report["bands"][0]["units"][0]["source_owners"] + .as_array() + .unwrap() + .len(), + 1 + ); + } + #[test] + fn center_uses_final_plan_f32_composition_and_translation_has_error_bound() { + let parents = BTreeMap::from([(id(1), None), (id(2), Some(id(1))), (id(3), Some(id(2)))]); + let offsets = BTreeMap::from([ + ( + id(1), + Position { + x: 0.0, + y: -100000.0, + }, + ), + ( + id(2), + Position { + x: 0.0, + y: 100000.0, + }, + ), + (id(3), Position { x: 0.0, y: 0.1 }), + ]); + let sizes = BTreeMap::from([( + id(3), + Size { + width: 10.0, + height: 10.0, + }, + )]); + assert_eq!( + center(id(3), &parents, &offsets, &sizes, &mut 0).unwrap(), + 45.1015625 + ); + assert_eq!(translate(10.125, 0.375).unwrap(), 10.5); + assert!(translate(32768.0, 0.002).is_err()); + } +}