411 lines
15 KiB
Diff
411 lines
15 KiB
Diff
diff --git a/crates/oa-core/tests/experimental_port_policy_falsifiers.rs b/crates/oa-core/tests/experimental_port_policy_falsifiers.rs
|
|
new file mode 100644
|
|
index 0000000..ef6d197
|
|
--- /dev/null
|
|
+++ b/crates/oa-core/tests/experimental_port_policy_falsifiers.rs
|
|
@@ -0,0 +1,404 @@
|
|
+//! Prospective acceptance falsifiers, intentionally expected to fail at 7e25b3d.
|
|
+//! Do not change the policy or relax these criteria before preserving first-run
|
|
+//! receipts. Source-derived predictions are diagnostic text, not test outcomes.
|
|
+use oa_core::{projection::*, routing::*, *};
|
|
+use std::{fs::OpenOptions, io::Write, path::PathBuf};
|
|
+
|
|
+const POLICIES: [ExperimentalPortPolicy; 3] = [
|
|
+ ExperimentalPortPolicy::Current,
|
|
+ ExperimentalPortPolicy::SameSidesAdaptSpacing,
|
|
+ ExperimentalPortPolicy::CapacityAware,
|
|
+];
|
|
+const SIDES: [PortAnchorSide; 4] = [
|
|
+ PortAnchorSide::Left,
|
|
+ PortAnchorSide::Right,
|
|
+ PortAnchorSide::Top,
|
|
+ PortAnchorSide::Bottom,
|
|
+];
|
|
+fn id(n: u128) -> Id {
|
|
+ Id::from_u128(0x956a2026_0912_4000_8000_000000000000 + n)
|
|
+}
|
|
+fn pos(x: f32, y: f32) -> Position {
|
|
+ Position { x, y }
|
|
+}
|
|
+fn size(width: f32, height: f32) -> Size {
|
|
+ Size { width, height }
|
|
+}
|
|
+fn element(p: &mut Project, n: u128, kind: Kind, name: &str) -> Id {
|
|
+ let mut e = p.make_element(kind, Layer::Sa, name, None).unwrap();
|
|
+ e.id = id(n);
|
|
+ p.elements.insert(e.id, e);
|
|
+ id(n)
|
|
+}
|
|
+fn standard(p: &mut Project, n: u128, owner: Id, name: &str) -> Id {
|
|
+ let port = make_port(
|
|
+ p,
|
|
+ id(n),
|
|
+ owner,
|
|
+ name,
|
|
+ PortContract::Component {
|
|
+ kind: ComponentPortKind::Standard,
|
|
+ orientation: ComponentPortOrientation::Unset,
|
|
+ },
|
|
+ )
|
|
+ .unwrap();
|
|
+ p.elements.insert(port.id, port);
|
|
+ id(n)
|
|
+}
|
|
+fn place(p: &mut Project, d: Id, n: u128, e: Id, at: Position, dimensions: Size) -> Id {
|
|
+ let generated = p.place(d, e, at);
|
|
+ let nodes = &mut p.diagrams.get_mut(&d).unwrap().nodes;
|
|
+ let mut node = nodes.remove(&generated).unwrap();
|
|
+ node.id = id(n);
|
|
+ node.size = dimensions;
|
|
+ nodes.insert(node.id, node);
|
|
+ id(n)
|
|
+}
|
|
+struct Fixture {
|
|
+ p: Project,
|
|
+ d: Id,
|
|
+ owner: Id,
|
|
+ ports: Vec<Id>,
|
|
+}
|
|
+fn right_peer(count: usize, height: f32, obstacle: bool) -> Fixture {
|
|
+ let mut p = Project::empty();
|
|
+ p.id = id(1);
|
|
+ let a = element(&mut p, 10, Kind::System, "Owner");
|
|
+ let b = element(&mut p, 11, Kind::SystemActor, "Far right peer");
|
|
+ let pb = standard(&mut p, 20, b, "Peer interface");
|
|
+ let mut ports = Vec::new();
|
|
+ for i in 0..count {
|
|
+ let pa = standard(&mut p, 100 + i as u128, a, &format!("Interface {i}"));
|
|
+ let generated = p.relate(
|
|
+ RelationKind::BoundaryExchange,
|
|
+ &format!("Exchange {i}"),
|
|
+ pa,
|
|
+ pb,
|
|
+ );
|
|
+ let mut relation = p.relations.remove(&generated).unwrap();
|
|
+ relation.id = id(1000 + i as u128);
|
|
+ p.relations.insert(relation.id, relation);
|
|
+ ports.push(pa);
|
|
+ }
|
|
+ let generated = p.ensure_diagram(Layer::Sa);
|
|
+ let mut diagram = p.diagrams.remove(&generated).unwrap();
|
|
+ diagram.id = id(30);
|
|
+ let d = diagram.id;
|
|
+ p.diagrams.insert(d, diagram);
|
|
+ let owner = place(&mut p, d, 40, a, pos(0., 0.), size(224., height));
|
|
+ place(&mut p, d, 41, b, pos(1908., 336.), size(184., 132.));
|
|
+ if obstacle {
|
|
+ let c = element(&mut p, 12, Kind::SystemActor, "Unrelated top obstacle");
|
|
+ place(&mut p, d, 42, c, pos(0., -150.), size(224., 132.));
|
|
+ }
|
|
+ validate_candidate(&p).unwrap();
|
|
+ assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
|
+ Fixture { p, d, owner, ports }
|
|
+}
|
|
+fn project(f: &Fixture, policy: ExperimentalPortPolicy) -> ProjectedView {
|
|
+ let view = project_diagram_with_experimental_port_policy(&f.p, &f.p.diagrams[&f.d], policy);
|
|
+ assert!(view.nodes.iter().all(|node| node.visual_parent.is_none()));
|
|
+ assert_eq!(view.edges.len(), f.ports.len());
|
|
+ view
|
|
+}
|
|
+fn owner<'a>(f: &Fixture, view: &'a ProjectedView) -> &'a ProjectedNode {
|
|
+ view.nodes.iter().find(|n| n.id == f.owner).unwrap()
|
|
+}
|
|
+fn port(view: &ProjectedView, id: Id) -> &ProjectedPort {
|
|
+ view.ports.iter().find(|p| p.element == id).unwrap()
|
|
+}
|
|
+fn side_counts(f: &Fixture, view: &ProjectedView) -> [usize; 4] {
|
|
+ SIDES.map(|side| {
|
|
+ view.ports
|
|
+ .iter()
|
|
+ .filter(|p| p.owner == f.owner && p.side == side)
|
|
+ .count()
|
|
+ })
|
|
+}
|
|
+fn gap(f: &Fixture, view: &ProjectedView) -> f32 {
|
|
+ let mut minimum = f32::INFINITY;
|
|
+ for side in SIDES {
|
|
+ let mut offsets: Vec<_> = view
|
|
+ .ports
|
|
+ .iter()
|
|
+ .filter(|p| p.owner == f.owner && p.side == side)
|
|
+ .map(|p| match side {
|
|
+ PortAnchorSide::Left | PortAnchorSide::Right => p.position.y,
|
|
+ _ => p.position.x,
|
|
+ })
|
|
+ .collect();
|
|
+ offsets.sort_by(f32::total_cmp);
|
|
+ for pair in offsets.windows(2) {
|
|
+ minimum = minimum.min(pair[1] - pair[0]);
|
|
+ }
|
|
+ }
|
|
+ minimum
|
|
+}
|
|
+// Independent segment/slab test, not the router's private Rect::crosses helper.
|
|
+// All fixture nodes are unrelated flat obstacles, so ancestor/header exceptions
|
|
+// do not apply. A node's own outward stub is allowed through its own clearance.
|
|
+fn stub_hits(port: &ProjectedPort, obstacle: &ProjectedNode) -> bool {
|
|
+ if port.owner == obstacle.id {
|
|
+ return false;
|
|
+ }
|
|
+ let normal = port.side.normal();
|
|
+ let mut lo = 0.0_f32;
|
|
+ let mut hi = 1.0_f32;
|
|
+ for (start, delta, min, max) in [
|
|
+ (
|
|
+ port.position.x,
|
|
+ normal.x * PORT_STUB,
|
|
+ obstacle.position.x - CLEARANCE,
|
|
+ obstacle.position.x + obstacle.size.width + CLEARANCE,
|
|
+ ),
|
|
+ (
|
|
+ port.position.y,
|
|
+ normal.y * PORT_STUB,
|
|
+ obstacle.position.y - CLEARANCE,
|
|
+ obstacle.position.y + obstacle.size.height + CLEARANCE,
|
|
+ ),
|
|
+ ] {
|
|
+ if delta == 0. {
|
|
+ if start <= min || start >= max {
|
|
+ return false;
|
|
+ }
|
|
+ } else {
|
|
+ let a = (min - start) / delta;
|
|
+ let b = (max - start) / delta;
|
|
+ lo = lo.max(a.min(b));
|
|
+ hi = hi.min(a.max(b));
|
|
+ }
|
|
+ }
|
|
+ lo < hi
|
|
+}
|
|
+fn blocked_stubs(view: &ProjectedView) -> Vec<(Id, Id)> {
|
|
+ view.ports
|
|
+ .iter()
|
|
+ .flat_map(|p| {
|
|
+ view.nodes
|
|
+ .iter()
|
|
+ .filter(move |n| stub_hits(p, n))
|
|
+ .map(move |n| (p.element, n.id))
|
|
+ })
|
|
+ .collect()
|
|
+}
|
|
+fn measure(f: &Fixture, view: &ProjectedView, label: &str) -> RoutedView {
|
|
+ let routed = route_diagram(&f.p, &f.p.diagrams[&f.d], view);
|
|
+ let mut length = 0.0_f32;
|
|
+ let mut bends = 0;
|
|
+ for edge in &view.edges {
|
|
+ let route = &routed.edges[&edge.id];
|
|
+ assert_eq!(
|
|
+ route.points.first(),
|
|
+ Some(&port(view, edge.source_port.unwrap()).position)
|
|
+ );
|
|
+ assert_eq!(
|
|
+ route.points.last(),
|
|
+ Some(&port(view, edge.target_port.unwrap()).position)
|
|
+ );
|
|
+ for pair in route.points.windows(2) {
|
|
+ assert!(pair[0].x == pair[1].x || pair[0].y == pair[1].y);
|
|
+ length += (pair[0].x - pair[1].x).abs() + (pair[0].y - pair[1].y).abs();
|
|
+ }
|
|
+ for triple in route.points.windows(3) {
|
|
+ let a = pos(triple[1].x - triple[0].x, triple[1].y - triple[0].y);
|
|
+ let b = pos(triple[2].x - triple[1].x, triple[2].y - triple[1].y);
|
|
+ if a.x * b.y != a.y * b.x {
|
|
+ bends += 1;
|
|
+ }
|
|
+ }
|
|
+ println!(
|
|
+ "{label} edge={} points={:?} warnings={:?}",
|
|
+ edge.id, route.points, route.warnings
|
|
+ );
|
|
+ }
|
|
+ let warnings: usize = routed.edges.values().map(|e| e.warnings.len()).sum();
|
|
+ println!(
|
|
+ "{label} bounds={:?}/{:?} LRTB={:?} gap={} warnings={warnings} length={length} bends={bends} blocked_stubs={:?}",
|
|
+ owner(f, view).position,
|
|
+ owner(f, view).size,
|
|
+ side_counts(f, view),
|
|
+ gap(f, view),
|
|
+ blocked_stubs(view)
|
|
+ );
|
|
+ for id in &f.ports {
|
|
+ println!(
|
|
+ "{label} port={id} side={:?} position={:?}",
|
|
+ port(view, *id).side,
|
|
+ port(view, *id).position
|
|
+ );
|
|
+ }
|
|
+ // A caller may retain exact model, projection and route geometry. Never
|
|
+ // overwrite a previous run. Ordinary export still chooses Current.
|
|
+ if let Some(directory) = std::env::var_os("OA_PORT_POLICY_RECEIPTS") {
|
|
+ let directory = PathBuf::from(directory);
|
|
+ std::fs::create_dir_all(&directory).unwrap();
|
|
+ for (suffix, content) in [
|
|
+ ("arcadia", language::format(&f.p)),
|
|
+ (
|
|
+ "projection.json",
|
|
+ serde_json::to_string_pretty(view).unwrap(),
|
|
+ ),
|
|
+ ("routing.txt", format!("{routed:#?}")),
|
|
+ ] {
|
|
+ let mut file = OpenOptions::new()
|
|
+ .write(true)
|
|
+ .create_new(true)
|
|
+ .open(directory.join(format!("{label}.{suffix}")))
|
|
+ .unwrap();
|
|
+ file.write_all(content.as_bytes()).unwrap();
|
|
+ }
|
|
+ }
|
|
+ routed
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn a_point_one_height_increase_does_not_needlessly_flip_twelve_of_twenty_ports() {
|
|
+ let mut f = right_peer(20, 259.9, false);
|
|
+ let mut violations = Vec::new();
|
|
+ for policy in POLICIES {
|
|
+ let mut views = Vec::new();
|
|
+ for height in [259.9, 260., 260.1] {
|
|
+ f.p.diagrams
|
|
+ .get_mut(&f.d)
|
|
+ .unwrap()
|
|
+ .nodes
|
|
+ .get_mut(&f.owner)
|
|
+ .unwrap()
|
|
+ .size
|
|
+ .height = height;
|
|
+ let before = f.p.clone();
|
|
+ let view = project(&f, policy);
|
|
+ measure(&f, &view, &format!("threshold-{policy:?}-{height}"));
|
|
+ assert_eq!(f.p, before, "Projection/routing must not author geometry");
|
|
+ if policy != ExperimentalPortPolicy::Current {
|
|
+ assert_eq!(owner(&f, &view).size, size(224., height));
|
|
+ }
|
|
+ views.push(view);
|
|
+ }
|
|
+ let flips: Vec<_> = views
|
|
+ .windows(2)
|
|
+ .map(|pair| {
|
|
+ f.ports
|
|
+ .iter()
|
|
+ .filter(|id| port(&pair[0], **id).side != port(&pair[1], **id).side)
|
|
+ .count()
|
|
+ })
|
|
+ .collect();
|
|
+ println!(
|
|
+ "threshold {policy:?}: adjacent side changes={flips:?}; source prediction capacity=[12,0], controls=[0,0]"
|
|
+ );
|
|
+ if policy == ExperimentalPortPolicy::CapacityAware {
|
|
+ // The prior assignment is still feasible at the larger rectangle.
|
|
+ // Nominal independent capacities: floor((edge - 36) / 28) + 1.
|
|
+ let new = owner(&f, &views[1]);
|
|
+ let capacities = [
|
|
+ new.size.height,
|
|
+ new.size.height,
|
|
+ new.size.width,
|
|
+ new.size.width,
|
|
+ ]
|
|
+ .map(|length| ((length - 36.) / 28.).floor() as usize + 1);
|
|
+ assert!(
|
|
+ side_counts(&f, &views[0])
|
|
+ .into_iter()
|
|
+ .zip(capacities)
|
|
+ .all(|(n, c)| n <= c)
|
|
+ );
|
|
+ assert!(gap(&f, &views[0]) >= 27.99);
|
|
+ // A single relocation can use the new preferred-side slot. This
|
|
+ // fixture-specific bound does not prohibit justified side changes.
|
|
+ if flips.iter().any(|n| *n > 1) {
|
|
+ violations.push(format!(
|
|
+ "CapacityAware changes {flips:?} sides while zero/one move suffices"
|
|
+ ));
|
|
+ }
|
|
+ } else if flips != [0, 0] {
|
|
+ violations.push(format!(
|
|
+ "{policy:?} control unexpectedly changes {flips:?} sides"
|
|
+ ));
|
|
+ }
|
|
+ }
|
|
+ assert!(
|
|
+ violations.is_empty(),
|
|
+ "Prospective resize-stability criterion failed: {violations:?}"
|
|
+ );
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn capacity_spillover_does_not_add_a_blocked_mandatory_stub_above_the_owner() {
|
|
+ let f = right_peer(8, 160., true);
|
|
+ let original = f.p.clone();
|
|
+ let mut violations = Vec::new();
|
|
+ for policy in POLICIES {
|
|
+ let view = project(&f, policy);
|
|
+ let routed = measure(&f, &view, &format!("obstacle-{policy:?}"));
|
|
+ let actual = owner(&f, &view);
|
|
+ assert_eq!(actual.position, pos(0., 0.));
|
|
+ assert_eq!(
|
|
+ actual.size,
|
|
+ size(
|
|
+ 224.,
|
|
+ if policy == ExperimentalPortPolicy::Current {
|
|
+ 232.
|
|
+ } else {
|
|
+ 160.
|
|
+ }
|
|
+ )
|
|
+ );
|
|
+ let obstacle = view.nodes.iter().find(|n| n.id == id(42)).unwrap();
|
|
+ assert_eq!(
|
|
+ (obstacle.position, obstacle.size),
|
|
+ (pos(0., -150.), size(224., 132.))
|
|
+ );
|
|
+ let blocked = blocked_stubs(&view);
|
|
+ let no_clear: Vec<_> = routed
|
|
+ .edges
|
|
+ .values()
|
|
+ .filter(|e| e.warnings.contains(&RouteWarning::NoClearPath))
|
|
+ .map(|e| e.id)
|
|
+ .collect();
|
|
+ println!(
|
|
+ "obstacle {policy:?}: independently blocked={blocked:?}, NoClearPath={no_clear:?}; source prediction capacity Top=(112,0), stub=(112,-18), obstacle inflation y=[-162,-6]"
|
|
+ );
|
|
+ if policy == ExperimentalPortPolicy::CapacityAware {
|
|
+ // Record whether each physically blocked source also reports the
|
|
+ // predicted route warning; do not use warning text as geometry proof.
|
|
+ for (port_id, obstacle_id) in &blocked {
|
|
+ let edges: Vec<_> = view
|
|
+ .edges
|
|
+ .iter()
|
|
+ .filter(|e| e.source_port == Some(*port_id) || e.target_port == Some(*port_id))
|
|
+ .map(|e| e.id)
|
|
+ .collect();
|
|
+ println!(
|
|
+ "blocked port={port_id} obstacle={obstacle_id} affected_edges={edges:?} all_NoClearPath={}",
|
|
+ edges.iter().all(|e| no_clear.contains(e))
|
|
+ );
|
|
+ }
|
|
+ if !blocked.is_empty() || routed.edges.values().any(|e| !e.warnings.is_empty()) {
|
|
+ violations.push(format!(
|
|
+ "CapacityAware introduces blocked stubs {blocked:?} or routed warnings"
|
|
+ ));
|
|
+ }
|
|
+ } else {
|
|
+ if !f
|
|
+ .ports
|
|
+ .iter()
|
|
+ .all(|p| port(&view, *p).side == PortAnchorSide::Right)
|
|
+ {
|
|
+ violations.push(format!("{policy:?} control no longer keeps Right exits"));
|
|
+ }
|
|
+ if !blocked.is_empty() || routed.edges.values().any(|e| !e.warnings.is_empty()) {
|
|
+ violations.push(format!(
|
|
+ "{policy:?} control has blocked stubs {blocked:?} or routed warnings"
|
|
+ ));
|
|
+ }
|
|
+ }
|
|
+ assert_eq!(f.p, original);
|
|
+ }
|
|
+ assert!(
|
|
+ violations.is_empty(),
|
|
+ "Prospective exit-clearance criterion failed: {violations:?}"
|
|
+ );
|
|
+}
|