From 2be8c7f2929684a99bfb92b63e1585980d6313cf Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Thu, 24 Sep 2026 20:38:33 -0700 Subject: [PATCH] experiment(layout): diagnose mandatory leads and test bounded flat separation --- crates/oa-core/examples/auto_layout_probe.rs | 96 +++++- .../examples/auto_layout_probe/clearance.rs | 278 ++++++++++++++++++ crates/oa-core/src/routing.rs | 116 +++++--- crates/oa-core/src/routing/leads.rs | 173 +++++++++++ crates/oa-core/tests/lead_feasibility.rs | 233 +++++++++++++++ .../placement-clearance-20260925/plan.md | 43 +++ 6 files changed, 898 insertions(+), 41 deletions(-) create mode 100644 crates/oa-core/examples/auto_layout_probe/clearance.rs create mode 100644 crates/oa-core/src/routing/leads.rs create mode 100644 crates/oa-core/tests/lead_feasibility.rs create mode 100644 harness/experiments/placement-clearance-20260925/plan.md diff --git a/crates/oa-core/examples/auto_layout_probe.rs b/crates/oa-core/examples/auto_layout_probe.rs index 744b7863..196691d6 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/clearance.rs"] +mod clearance; #[path = "auto_layout_probe/layered.rs"] mod layered; @@ -1002,6 +1004,94 @@ fn import_placement_cli( "final_projected_ports":projection::project_view(&after,diagram).ports}) ); } +fn lead_diagnostics(project: &Project, diagram: Id) -> Value { + let view = projection::project_view(project, diagram); + let endpoint = |e: &routing::LeadEndpoint| { + json!({"owner":e.owner,"tip":e.tip,"stub":e.stub, + "blockers":e.blockers.iter().map(|b|json!({"id":b.id,"bounds":{"position":b.bounds.position,"size":b.bounds.size}})).collect::>(), + "stub_containers":e.stub_containers.iter().map(|b|json!({"id":b.id,"bounds":{"position":b.bounds.position,"size":b.bounds.size}})).collect::>()}) + }; + json!( + routing::lead_incompatibilities(project, &project.diagrams[&diagram], &view) + .iter() + .map( + |r| json!({"edge":r.edge,"allowed_pair_count":r.allowed_pair_count, + "lead_clear_pair_count":r.lead_clear_pair_count, + "source":r.source.iter().map(&endpoint).collect::>(), + "target":r.target.iter().map(&endpoint).collect::>()}) + ) + .collect::>() + ) +} + +fn repaired_candidate(before: &Project, diagram: Id) -> Result { + let geometry = clearance::repair(before, diagram)?; + let view = projection::project_view(&geometry, diagram); + // Reconstruct metadata once from original to final geometry with the same + // audited reset/translation policy used by the placement reference. + import_placement( + before, + diagram, + PlacementPlan { + schema: 1, + nodes: view + .nodes + .iter() + .map(|n| PlacementNode { + id: n.id, + position: n.position, + size: n.size, + }) + .collect(), + }, + ) +} + +fn repair_placement_cli(project_file: &std::path::Path, diagram_text: &std::ffi::OsStr) { + let before: 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"); + validate_candidate(&before).expect("Validate input"); + assert!(before.diagrams.contains_key(&diagram), "Missing diagram"); + let candidate = match repaired_candidate(&before, diagram) { + Err(error) => json!({"supported":false,"error":error.to_string()}), + Ok(after) => { + let duplicate = repaired_candidate(&before, diagram); + let mut previous = after.clone(); + let mut stability = Vec::new(); + for pass in 2..=10 { + match repaired_candidate(&previous, diagram) { + Ok(next) => { + stability.push(json!({"pass":pass,"equal_previous":next==previous,"equal_first":next==after})); + previous = next; + } + Err(error) => { + stability.push(json!({"pass":pass,"error":error.to_string()})); + break; + } + } + } + json!({"supported":true,"project":after,"metrics":metrics(&after,diagram), + "lead_incompatibilities":lead_diagnostics(&after,diagram), + "conservation":conservation(&before,&after,diagram),"roundtrip":roundtrip(&after), + "same_input_exact":duplicate.as_ref().is_ok_and(|p|*p==after), + "ten_repair_calls":stability}) + } + }; + println!( + "{}", + json!({"probe":"placement_clearance_v1","diagram":diagram, + "scope":"Experimental flat monotone separation only; lead clearance is necessary, not sufficient routing or readability acceptance. Unsupported topology and unresolved constraints remain explicit. Existing router and command behavior unchanged.", + "before_project":before,"before":metrics(&before,diagram), + "before_lead_incompatibilities":lead_diagnostics(&before,diagram),"candidate":candidate}) + ); +} + fn arrange_corpus(path: &std::path::Path) { let manifest: Value = serde_json::from_slice(&std::fs::read(path).expect("Read manifest")) .expect("Decode manifest"); @@ -1107,10 +1197,14 @@ fn main() { ); return; } + if args.len() == 4 && args[1] == "--repair-placement" { + repair_placement_cli(std::path::Path::new(&args[2]), &args[3]); + return; + } assert_eq!( args.len(), 1, - "Usage: auto_layout_probe [--route-corpus MANIFEST | --arrange-corpus MANIFEST | --import-placement PROJECT UUID PLAN]" + "Usage: auto_layout_probe [--route-corpus MANIFEST | --arrange-corpus MANIFEST | --import-placement PROJECT UUID PLAN | --repair-placement PROJECT UUID]" ); println!( "{}", diff --git a/crates/oa-core/examples/auto_layout_probe/clearance.rs b/crates/oa-core/examples/auto_layout_probe/clearance.rs new file mode 100644 index 00000000..c156d263 --- /dev/null +++ b/crates/oa-core/examples/auto_layout_probe/clearance.rs @@ -0,0 +1,278 @@ +//! Bounded flat-placement experiment. Empty lead diagnostics are necessary only, +//! never a claim that routes, labels, or drawing quality are acceptable. +use oa_core::{Id, ModelError, Project, projection, routing}; +use std::collections::BTreeMap; + +const ROUNDS: usize = 4; +const WORK_LIMIT: usize = 10_000_000; +const MAX_SPAN: f32 = 100_000.0; +const MARGIN: f32 = 1.0; + +fn fail(message: &str) -> ModelError { + ModelError(format!("Clearance probe: {message}")) +} + +fn spend(work: &mut usize, amount: usize) -> Result<(), ModelError> { + *work = work.saturating_add(amount); + if *work > WORK_LIMIT { + return Err(fail("work limit exceeded")); + } + Ok(()) +} + +// A boundary constraint means start[to] >= start[from] + extent[from] + gap. +// Adjacent total-order constraints below separately preserve start ordering. +type Constraints = BTreeMap<(usize, usize), f32>; + +fn solve( + baseline: &[f32], + extents: &[f32], + order: &[usize], + constraints: &Constraints, + work: &mut usize, +) -> Result, ModelError> { + let mut result = baseline.to_vec(); + let mut previous = None; + for &to in order { + if let Some(from) = previous { + result[to] = result[to].max(result[from]); + } + for (&(from, target), &gap) in constraints { + spend(work, 1)?; + if target == to { + result[to] = result[to].max(result[from] + extents[from] + gap); + } + } + previous = Some(to); + } + let minimum = baseline.iter().copied().fold(f32::INFINITY, f32::min); + for (i, &value) in result.iter().enumerate() { + if !value.is_finite() || value + extents[i] - minimum > MAX_SPAN { + return Err(fail("coordinate/span bound exceeded")); + } + } + Ok(result) +} + +/// Input is an already admitted placement candidate. Never modifies its routes, +/// authored anchors, sizes or semantics. Error drops the private clone. +pub fn repair(project: &Project, diagram: Id) -> Result { + let original = project + .diagrams + .get(&diagram) + .ok_or_else(|| fail("missing diagram"))?; + if original.projection.scenario().is_some() { + return Err(fail("Scenario unsupported")); + } + let baseline = projection::project_diagram(project, original); + if baseline.nodes.len() > 256 || baseline.edges.len() > 1024 { + return Err(fail("graph bound exceeded")); + } + if baseline.nodes.len() != original.nodes.len() + || baseline + .nodes + .iter() + .any(|n| n.collapsed || n.visual_parent.is_some()) + { + return Err(fail("nested, folded or omitted placements unsupported")); + } + let mut nodes = baseline.nodes.clone(); + nodes.sort_by_key(|n| n.id); + if nodes.iter().any(|n| { + !n.position.x.is_finite() + || !n.position.y.is_finite() + || !n.size.width.is_finite() + || !n.size.height.is_finite() + || n.size.width <= 0.0 + || n.size.height <= 0.0 + }) { + return Err(fail("invalid initial geometry")); + } + let indices: BTreeMap<_, _> = nodes.iter().enumerate().map(|(i, n)| (n.id, i)).collect(); + let coordinates = [ + nodes.iter().map(|n| n.position.x).collect::>(), + nodes.iter().map(|n| n.position.y).collect::>(), + ]; + let extents = [ + nodes.iter().map(|n| n.size.width).collect::>(), + nodes.iter().map(|n| n.size.height).collect::>(), + ]; + for axis in 0..2 { + let minimum = coordinates[axis] + .iter() + .copied() + .fold(f32::INFINITY, f32::min); + if coordinates[axis] + .iter() + .zip(&extents[axis]) + .any(|(&start, &extent)| { + !(start + extent).is_finite() || start + extent - minimum > MAX_SPAN + }) + { + return Err(fail("initial coordinate/span bound exceeded")); + } + } + let mut orders = [Vec::new(), Vec::new()]; + let mut ranks = [vec![0; nodes.len()], vec![0; nodes.len()]]; + for axis in 0..2 { + orders[axis] = (0..nodes.len()).collect(); + orders[axis].sort_by(|&a, &b| { + coordinates[axis][a] + .total_cmp(&coordinates[axis][b]) + .then(nodes[a].id.cmp(&nodes[b].id)) + }); + for (rank, &i) in orders[axis].iter().enumerate() { + ranks[axis][i] = rank; + } + } + let mut constraints = [Constraints::new(), Constraints::new()]; + let mut work = 0; + // Preserve one existing disjoint axis per pair, X before Y. No initially + // overlapping pair is silently treated as successfully repaired. + for a in 0..nodes.len() { + for b in a + 1..nodes.len() { + spend(&mut work, 1)?; + let mut chosen = None; + for axis in 0..2 { + for (from, to) in [(a, b), (b, a)] { + let gap = coordinates[axis][to] - coordinates[axis][from] - extents[axis][from]; + if gap >= 0.0 { + chosen = Some((axis, from, to, gap)); + break; + } + } + if chosen.is_some() { + break; + } + } + let (axis, from, to, gap) = + chosen.ok_or_else(|| fail("initial node overlap unsupported"))?; + constraints[axis].insert((from, to), gap); + } + } + let mut result = project.clone(); + for round in 0..=ROUNDS { + let current = &result.diagrams[&diagram]; + let view = projection::project_diagram(&result, current); + if view.nodes.len() != nodes.len() + || view.nodes.iter().any(|n| { + indices.get(&n.id).is_none_or(|&i| { + n.size != nodes[i].size || n.collapsed || n.visual_parent.is_some() + }) + }) + { + return Err(fail("reprojection changed node identity/size/topology")); + } + // Charge a conservative endpoint-pair/obstacle allowance before the + // diagnostic: at most four cardinal candidates at each endpoint. + spend( + &mut work, + view.edges + .len() + .saturating_mul(nodes.len().saturating_add(1)) + .saturating_mul(16), + )?; + let failures = routing::lead_incompatibilities(&result, current, &view); + if failures.is_empty() { + return Ok(result); + } + if round == ROUNDS { + return Err(fail("unresolved mandatory leads after four adjustments")); + } + let mut strengthened = false; + for failure in failures { + if failure.allowed_pair_count != 1 + || failure.source.len() != 1 + || failure.target.len() != 1 + { + return Err(fail("ambiguous endpoint choices unsupported")); + } + for endpoint in failure.source.iter().chain(&failure.target) { + let owner = *indices + .get(&endpoint.owner) + .ok_or_else(|| fail("unknown endpoint owner"))?; + let dx = endpoint.stub.x - endpoint.tip.x; + let dy = endpoint.stub.y - endpoint.tip.y; + if endpoint.blockers.is_empty() { + continue; + } + let (axis, forward) = match (dx, dy) { + (x, 0.0) if x != 0.0 => (0, x > 0.0), + (0.0, y) if y != 0.0 => (1, y > 0.0), + _ => return Err(fail("noncardinal/degenerate blocked lead")), + }; + for blocker in &endpoint.blockers { + spend(&mut work, 1)?; + let other = *indices + .get(&blocker.id) + .ok_or_else(|| fail("unknown blocker"))?; + let (from, to) = if forward { + (owner, other) + } else { + (other, owner) + }; + if ranks[axis][from] >= ranks[axis][to] { + return Err(fail("lead constraint opposes frozen spatial order")); + } + let required = routing::PORT_STUB + routing::CLEARANCE + MARGIN; + let old = constraints[axis].entry((from, to)).or_insert(0.0); + if *old < required { + *old = required; + strengthened = true; + } + } + } + } + if !strengthened { + return Err(fail( + "unresolved lead without a stronger monotone constraint", + )); + } + let x = solve( + &coordinates[0], + &extents[0], + &orders[0], + &constraints[0], + &mut work, + )?; + let y = solve( + &coordinates[1], + &extents[1], + &orders[1], + &constraints[1], + &mut work, + )?; + let target = result.diagrams.get_mut(&diagram).unwrap(); + for (i, node) in nodes.iter().enumerate() { + let placed = target.nodes.get_mut(&node.id).unwrap(); + placed.position.x = x[i]; + placed.position.y = y[i]; + } + } + unreachable!("bounded loop returns on final round") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn separation_propagates_to_neighbor_without_moving_predecessor() { + let constraints = BTreeMap::from([((0, 1), 31.0), ((1, 2), 10.0)]); + let actual = solve( + &[-20.0, 100.0, 210.0], + &[100.0; 3], + &[0, 1, 2], + &constraints, + &mut 0, + ) + .unwrap(); + assert_eq!(actual, vec![-20.0, 111.0, 221.0]); + } + + #[test] + fn extent_bound_rejects_constraint_expansion() { + let constraints = BTreeMap::from([((0, 1), MAX_SPAN)]); + assert!(solve(&[0.0, 20.0], &[10.0; 2], &[0, 1], &constraints, &mut 0).is_err()); + } +} diff --git a/crates/oa-core/src/routing.rs b/crates/oa-core/src/routing.rs index 4868d45b..edd0ae47 100644 --- a/crates/oa-core/src/routing.rs +++ b/crates/oa-core/src/routing.rs @@ -6,7 +6,9 @@ use crate::{ RelationBody, RelationKind, Size, }; mod attachments; +mod leads; pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint}; +pub use leads::{LeadBlocker, LeadEndpoint, LeadIncompatibility, lead_incompatibilities}; use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; use std::fmt; @@ -420,6 +422,73 @@ fn endpoints( .collect() } +fn effective_route<'a>( + project: &Project, + diagram: &Diagram, + edge: &ProjectedEdge, + stored: Option<&'a crate::EdgeRoute>, +) -> ( + bool, + Option<&'a crate::EdgeRoute>, + [Option; 2], +) { + let promoted = projection::edge_endpoint_placements(project, diagram, edge.id) + .is_some_and(|(source, target)| source != edge.source || target != edge.target); + let route = if promoted { None } else { stored }; + let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| { + semantic_port_endpoint(project, edge.id, end) + .is_none() + .then(|| route.and_then(|r| r.attachment(end))) + .flatten() + }); + (promoted, route, attachments) +} +#[allow(clippy::too_many_arguments)] +fn edge_endpoints( + project: &Project, + diagram: &Diagram, + edge: &ProjectedEdge, + source: &ProjectedNode, + target: &ProjectedNode, + view: &ProjectedView, + attachments: [Option; 2], +) -> [Vec; 2] { + [ + endpoints( + project, + diagram, + source, + semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port), + view, + attachments[0], + ), + endpoints( + project, + diagram, + target, + semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port), + view, + attachments[1], + ), + ] +} +fn allowed_endpoint_pair(edge: &ProjectedEdge, a: Endpoint, b: Endpoint, coincident: bool) -> bool { + !(edge.source == edge.target + && edge.source_port.is_none() + && edge.target_port.is_none() + && a.point == b.point + && a.stub == b.stub + && !coincident) +} +fn lead_pair_clear(edge: &ProjectedEdge, a: Endpoint, b: Endpoint, obstacles: &[Rect]) -> bool { + !obstacles + .iter() + .any(|r| r.id != edge.source && r.crosses(a.point, a.stub)) + && !obstacles + .iter() + .any(|r| r.id != edge.target && r.crosses(b.point, b.stub)) +} + fn ancestors(id: Id, nodes: &BTreeMap, out: &mut BTreeSet) { let mut current = nodes.get(&id).and_then(|n| n.visual_parent); let mut seen = BTreeSet::new(); @@ -512,16 +581,8 @@ fn route_diagram_impl( continue; }; let stored = authored.get(&edge.id).copied(); - let promoted = projection::edge_endpoint_placements(project, diagram, edge.id) - .is_some_and(|(source, target)| source != edge.source || target != edge.target); - let custom_suspended = promoted; - let route = if promoted { None } else { stored }; - let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| { - semantic_port_endpoint(project, edge.id, end) - .is_none() - .then(|| route.and_then(|r| r.attachment(end))) - .flatten() - }); + let (custom_suspended, route, attachments) = + effective_route(project, diagram, edge, stored); let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone()); let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers); @@ -559,24 +620,11 @@ fn route_diagram_impl( }); } } + let ends = edge_endpoints(project, diagram, edge, source, target, view, attachments); let (points, pin_lengths, failure) = route_edge( edge, - &endpoints( - project, - diagram, - source, - semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port), - view, - attachments[0], - ), - &endpoints( - project, - diagram, - target, - semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port), - view, - attachments[1], - ), + &ends[0], + &ends[1], &pins, &obstacles, &previous, @@ -793,13 +841,7 @@ fn route_edge( for &b in target_ends { // A real self-relation must leave and return at distinct anchors. // Reusing one anchor makes the cheapest path a degenerate stub. - if edge.source == edge.target - && edge.source_port.is_none() - && edge.target_port.is_none() - && a.point == b.point - && a.stub == b.stub - && !coincident - { + if !allowed_endpoint_pair(edge, a, b, coincident) { continue; } let estimate = manhattan(a.stub, pins.first().copied().unwrap_or(b.stub)) @@ -823,13 +865,7 @@ fn route_edge( }) { continue; } - if obstacles - .iter() - .any(|r| r.id != edge.source && r.crosses(a.point, a.stub)) - || obstacles - .iter() - .any(|r| r.id != edge.target && r.crosses(b.point, b.stub)) - { + if !lead_pair_clear(edge, a, b, obstacles) { continue; } let mut points = vec![a.point, a.stub]; diff --git a/crates/oa-core/src/routing/leads.rs b/crates/oa-core/src/routing/leads.rs new file mode 100644 index 00000000..b482574c --- /dev/null +++ b/crates/oa-core/src/routing/leads.rs @@ -0,0 +1,173 @@ +//! Necessary terminal-lead checks shared with experimental placement consumers. +use super::*; + +#[derive(Debug, Clone, PartialEq)] +pub struct LeadBlocker { + pub id: Id, + /// The actual routing obstacle: clearance inflated, ancestor body excluded. + pub bounds: RoutingBounds, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LeadEndpoint { + pub owner: Id, + pub tip: Position, + pub stub: Position, + pub blockers: Vec, + /// Separate information only; not part of the router's early lead guard. + pub stub_containers: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct LeadIncompatibility { + pub edge: Id, + pub source: Vec, + pub target: Vec, + pub allowed_pair_count: usize, + pub lead_clear_pair_count: usize, +} + +fn blocker(r: &Rect) -> LeadBlocker { + LeadBlocker { + id: r.id, + bounds: RoutingBounds { + position: pos(r.left, r.top), + size: Size { + width: r.right - r.left, + height: r.bottom - r.top, + }, + }, + } +} + +fn describe(owner: Id, endpoint: Endpoint, obstacles: &[Rect]) -> LeadEndpoint { + LeadEndpoint { + owner, + tip: endpoint.point, + stub: endpoint.stub, + blockers: obstacles + .iter() + .filter(|r| r.id != owner && r.crosses(endpoint.point, endpoint.stub)) + .map(blocker) + .collect(), + stub_containers: obstacles + .iter() + .filter(|r| r.contains(endpoint.stub)) + .map(blocker) + .collect(), + } +} + +/// Return only edges for which every allowed endpoint pair fails the router's +/// existing mandatory-lead guard. Empty output is NECESSARY ONLY: it does not +/// establish a clear route, clear pins, successful search, or label clearance. +/// Uses the supplied final projection; does not modify the Project or choose +/// different ports/attachments. Stub containment is reported separately and +/// does not change the lead guard or its historical pinned/degenerate behavior. +pub fn lead_incompatibilities( + project: &Project, + diagram: &Diagram, + view: &ProjectedView, +) -> Vec { + let nodes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n)).collect(); + let rectangles: Vec<_> = nodes.values().map(|n| Rect::node(n)).collect(); + let authored: BTreeMap<_, _> = diagram.routes.iter().map(|r| (r.edge, r)).collect(); + let headers: BTreeMap<_, _> = nodes + .values() + .map(|node| { + ( + node.id, + if diagram.projection.state_machine().is_some() && !node.collapsed { + projection::state_header_height(project, node.element) + } else { + HEADER + }, + ) + }) + .collect(); + let mut ordered: Vec<_> = view.edges.iter().collect(); + ordered.sort_by_key(|e| e.id); + let mut result = Vec::new(); + for edge in ordered { + let (Some(source), Some(target)) = (nodes.get(&edge.source), nodes.get(&edge.target)) + else { + continue; + }; + let (_, _, attachments) = + effective_route(project, diagram, edge, authored.get(&edge.id).copied()); + let ends = edge_endpoints(project, diagram, edge, source, target, view, attachments); + let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers); + let coincident = edge.source == edge.target + && ends[0].len() == 1 + && ends[1].len() == 1 + && ends[0][0].point == ends[1][0].point + && ends[0][0].stub == ends[1][0].stub; + let mut allowed_pair_count = 0; + let mut lead_clear_pair_count = 0; + for &a in &ends[0] { + for &b in &ends[1] { + if !allowed_endpoint_pair(edge, a, b, coincident) { + continue; + } + allowed_pair_count += 1; + if lead_pair_clear(edge, a, b, &obstacles) { + lead_clear_pair_count += 1; + } + } + } + if lead_clear_pair_count == 0 { + result.push(LeadIncompatibility { + edge: edge.id, + source: ends[0] + .iter() + .map(|&e| describe(edge.source, e, &obstacles)) + .collect(), + target: ends[1] + .iter() + .map(|&e| describe(edge.target, e, &obstacles)) + .collect(), + allowed_pair_count, + lead_clear_pair_count, + }); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coincident_selfloop_exception_and_stub_containment_remain_separate() { + let owner = Id::from_u128(1); + let edge = ProjectedEdge { + id: Id::from_u128(2), + source: owner, + target: owner, + source_port: None, + target_port: None, + trace: false, + oriented: true, + }; + let endpoint = Endpoint { + point: pos(0., 0.), + stub: pos(18., 0.), + }; + assert!(!allowed_endpoint_pair(&edge, endpoint, endpoint, false)); + assert!(allowed_endpoint_pair(&edge, endpoint, endpoint, true)); + let obstacles = [Rect { + id: owner, + left: -1., + right: 20., + top: -1., + bottom: 1., + }]; + assert!(lead_pair_clear(&edge, endpoint, endpoint, &obstacles)); + let diagnostic = describe(owner, endpoint, &obstacles); + assert!(diagnostic.blockers.is_empty()); + assert_eq!(diagnostic.stub_containers.len(), 1); + // This diagnostic must not be folded into the historical early guard. + assert_eq!(diagnostic.stub_containers[0].id, owner); + } +} diff --git a/crates/oa-core/tests/lead_feasibility.rs b/crates/oa-core/tests/lead_feasibility.rs new file mode 100644 index 00000000..c7ec5877 --- /dev/null +++ b/crates/oa-core/tests/lead_feasibility.rs @@ -0,0 +1,233 @@ +use oa_core::{projection, routing, *}; + +struct Fixture { + p: Project, + d: Id, + edge: Id, + source: Id, + target: Id, + blocker: Id, +} +fn fixture(ports: bool, gap: f32) -> Fixture { + let mut p = Project::empty(); + let kind = if ports { + Kind::Function + } else { + Kind::DataType + }; + let a = p.add_element(kind, Layer::Sa, "Source", None).unwrap(); + let b = p.add_element(kind, Layer::Sa, "Target", None).unwrap(); + let c = p.add_element(kind, Layer::Sa, "Blocker", None).unwrap(); + let edge = p.relate( + if ports { + RelationKind::FunctionalExchange + } else { + RelationKind::DataAssociation + }, + "Flow", + a, + b, + ); + let d = p.ensure_diagram(Layer::Sa); + let source = p.place(d, a, Position { x: 0., y: 0. }); + let target = p.place( + d, + b, + Position { + x: 500., + y: 180. + gap, + }, + ); + let blocker = p.place(d, c, Position { x: 500., y: 20. }); + for n in p.diagrams.get_mut(&d).unwrap().nodes.values_mut() { + n.size = Size { + width: 224., + height: 160., + }; + } + if ports { + for (owner, end, side) in [ + (source, EdgeEnd::Source, PortAnchorSide::Right), + (target, EdgeEnd::Target, PortAnchorSide::Top), + ] { + let port = routing::semantic_port_endpoint(&p, edge, end).unwrap(); + p.diagrams + .get_mut(&d) + .unwrap() + .nodes + .get_mut(&owner) + .unwrap() + .port_anchors + .push(PortAnchor { + port, + side, + offset: 0.5, + }); + } + } + validate_candidate(&p).unwrap(); + Fixture { + p, + d, + edge, + source, + target, + blocker, + } +} +fn report(f: &Fixture) -> Vec { + routing::lead_incompatibilities( + &f.p, + &f.p.diagrams[&f.d], + &projection::project_view(&f.p, f.d), + ) +} +fn attachment_route(edge: Id) -> EdgeRoute { + EdgeRoute { + edge, + source_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.25, + }), + target_attachment: Some(EdgeAttachment { + side: PortAnchorSide::Bottom, + offset: 0.75, + }), + waypoints: vec![Position { x: 300., y: 400. }], + label: None, + } +} + +#[test] +fn exact_twenty_gap_is_blocked_thirty_plus_clears_without_mutation() { + let f = fixture(true, 20.); + let original = f.p.clone(); + let r = report(&f); + assert_eq!(r.len(), 1); + assert_eq!( + ( + r[0].edge, + r[0].allowed_pair_count, + r[0].lead_clear_pair_count + ), + (f.edge, 1, 0) + ); + assert_eq!(r[0].source[0].owner, f.source); + let end = &r[0].target[0]; + assert_eq!(end.owner, f.target); + assert_eq!(end.tip, Position { x: 612., y: 200. }); + assert_eq!(end.stub, Position { x: 612., y: 182. }); + assert_eq!(end.blockers.len(), 1); + assert_eq!(end.blockers[0].id, f.blocker); + assert_eq!( + end.blockers[0].bounds, + routing::RoutingBounds { + position: Position { x: 488., y: 8. }, + size: Size { + width: 248., + height: 184. + }, + } + ); + assert_eq!(end.stub_containers, end.blockers); + assert_eq!(f.p, original); + assert!(report(&fixture(true, 30.)).is_empty()); + assert!(report(&fixture(true, 31.)).is_empty()); +} + +#[test] +fn semantic_manual_ports_override_dormant_route_attachments_and_pins() { + let mut f = fixture(true, 20.); + let expected = report(&f); + f.p.diagrams + .get_mut(&f.d) + .unwrap() + .routes + .push(attachment_route(f.edge)); + assert_eq!(report(&f), expected); + let view = projection::project_view(&f.p, f.d); + let routed = routing::route_diagram(&f.p, &f.p.diagrams[&f.d], &view); + assert_eq!( + routed.edges[&f.edge].points.last(), + Some(&expected[0].target[0].tip) + ); + assert!( + routed.edges[&f.edge] + .warnings + .contains(&routing::RouteWarning::NoClearPath) + ); +} + +#[test] +fn unconstrained_nonport_alternative_is_not_hard_blocked() { + let mut f = fixture(false, 20.); + assert!(report(&f).is_empty()); + let mut route = attachment_route(f.edge); + route.source_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Right, + offset: 0.5, + }); + route.target_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.5, + }); + f.p.diagrams.get_mut(&f.d).unwrap().routes.push(route); + let r = report(&f); + assert_eq!(r.len(), 1); + assert_eq!(r[0].allowed_pair_count, 1); + assert_eq!(r[0].target[0].blockers[0].id, f.blocker); +} + +#[test] +fn nested_endpoint_body_is_traversable_and_promoted_attachments_are_suspended() { + let mut f = fixture(false, 20.); + // DataType containment is not required for this routing-only view witness: + // construct the supplied projected forest explicitly, retaining real owners. + let mut view = projection::project_view(&f.p, f.d); + let parent = Id::new_v4(); + let source = view.nodes.iter_mut().find(|n| n.id == f.source).unwrap(); + source.visual_parent = Some(parent); + source.position = Position { x: 40., y: 100. }; + let element = source.element; + view.nodes.push(projection::ProjectedNode { + id: parent, + element, + position: Position { x: 0., y: 0. }, + size: Size { + width: 300., + height: 300., + }, + visual_parent: None, + depth: 0, + collapsed: false, + allocation_summary: false, + }); + let mut route = attachment_route(f.edge); + route.source_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Right, + offset: 0.5, + }); + route.target_attachment = Some(EdgeAttachment { + side: PortAnchorSide::Top, + offset: 0.5, + }); + f.p.diagrams.get_mut(&f.d).unwrap().routes.push(route); + let r = routing::lead_incompatibilities(&f.p, &f.p.diagrams[&f.d], &view); + assert_eq!(r.len(), 1); + assert!( + r[0].source[0].blockers.is_empty(), + "Ancestor body must not block source lead" + ); + // Promotion changes actual projected owner: custom attachments no longer apply. + view.edges + .iter_mut() + .find(|e| e.id == f.edge) + .unwrap() + .source = parent; + let with_dormant = routing::lead_incompatibilities(&f.p, &f.p.diagrams[&f.d], &view); + f.p.diagrams.get_mut(&f.d).unwrap().routes.clear(); + assert_eq!( + with_dormant, + routing::lead_incompatibilities(&f.p, &f.p.diagrams[&f.d], &view) + ); +} diff --git a/harness/experiments/placement-clearance-20260925/plan.md b/harness/experiments/placement-clearance-20260925/plan.md new file mode 100644 index 00000000..bb16f305 --- /dev/null +++ b/harness/experiments/placement-clearance-20260925/plan.md @@ -0,0 +1,43 @@ +# Placement clearance experiment + +Continuation of issue32 from connection-layout-reference results at2850b915. +The sparse external placement leaves20 units between rectangles where its +final Rust top port needs18 units of outward lead plus12 units of unrelated +obstacle clearance. This experiment addresses that necessary placement +condition. It does not adopt either previous placement candidate. + +Use the router's actual endpoint selection, promotion/attachment policy and +lead collision predicate in a narrow read-only diagnostic. Preserve existing +router guard order, search, costs and fallback. Compare the five-case frozen +route output exactly with the released-router control, and exercise targeted +lead, alternative-endpoint and folded/ancestor tests. + +First consumer is an isolated example-only flat repair: at most256 nodes, +1024 edges and four separation rounds. Freeze axis order and initial coordinate +floors; seed separation inequalities from every initially disjoint node pair. +Add a31-unit boundary gap (18+12+1 margin) for unambiguous cardinal singleton +lead blockers and recompute monotone coordinates. Reproject actual automatic +ports each round. Reject overlapping input, unsupported nested/folded/omitted +topology, conflicting order, projection size changes, excessive extent/work, +or unresolved constraints. No mutation escapes on failure. This narrower +first experiment deliberately leaves subtree/compound coordinate assignment +for a later planner increment. + +Evaluate all five frozen original and Arrange-reset scenes, all four supported +old native candidates, and all three prior ELK geometry transfers, unchanged. +Keep unsupported cases and failures explicit. Reconstruct route reset/retained +translation once from input to final geometry using the audited import helper. +Check semantic/anchor/other-view conservation, round trips, repeat determinism +and ten repeated repair calls. Report all existing quality measures, actual +port changes, warnings and unrelated-node penetrations. Require the exact sparse +terminal conflict to disappear without introducing a new node overlap or routing +failure before calling this compatibility increment successful. Lead clearance +alone is not complete routability or readability acceptance. + +Stop after one bounded candidate and independent review. If the sparse witness +cannot be repaired under frozen orders, retain the counterexample and move +constraints into initial coordinate assignment; do not increase iteration +counts or tune the corpus. Even success leaves prior shared-run, area, +compound/undirected and native usability limitations unresolved. + +Windows only. No Linux build, release change or production Arrange adoption.