376 lines
16 KiB
Diff
376 lines
16 KiB
Diff
diff --git a/crates/oa-core/src/projection.rs b/crates/oa-core/src/projection.rs
|
|
index 2ee820e3c88eda1245ea17fe36965d5e5643cf5e..b6f71ab24e23a7918260e239f102ac17552d5828 100644
|
|
--- a/crates/oa-core/src/projection.rs
|
|
+++ b/crates/oa-core/src/projection.rs
|
|
@@ -12,6 +12,7 @@ pub enum ExperimentalPortPolicy {
|
|
Current,
|
|
SameSidesAdaptSpacing,
|
|
CapacityAware,
|
|
+ CapacityAwareStableRanking,
|
|
}
|
|
#[path = "projection/state.rs"]
|
|
mod state;
|
|
diff --git a/crates/oa-core/src/projection/port_capacity_experiment.rs b/crates/oa-core/src/projection/port_capacity_experiment.rs
|
|
index 25bdab04371cafee66c6bb51bb47c11c873e976c..29723b86dcb91985b7374f9553e1aaf9b20b0299 100644
|
|
--- a/crates/oa-core/src/projection/port_capacity_experiment.rs
|
|
+++ b/crates/oa-core/src/projection/port_capacity_experiment.rs
|
|
@@ -31,7 +31,22 @@ fn counts(assigned: &[Option<PortAnchorSide>]) -> [usize; 4] {
|
|
result
|
|
}
|
|
|
|
-fn ranked(demand: &Demand, counts: [usize; 4]) -> Vec<PortAnchorSide> {
|
|
+// Explicit reproducible identity/side ranking for the fourth experimental mode.
|
|
+// UUID bits are interpreted as u128, with low64 XOR high64.rotate_left(17).
|
|
+// Each side uses (L/R/T/B index + 1) * the golden-ratio constant as a salt.
|
|
+// The SplitMix64 finalizer below uses wrapping arithmetic. Largest score wins;
|
|
+// equal scores use L/R/T/B order. No occupancy, overflow ordinal or runtime hash.
|
|
+fn identity_score(port: Id, side: PortAnchorSide) -> u64 {
|
|
+ let bits = port.as_u128();
|
|
+ let mut value = bits as u64
|
|
+ ^ ((bits >> 64) as u64).rotate_left(17)
|
|
+ ^ (index(side) as u64 + 1).wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
|
+ value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
|
+ value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
|
+ value ^ (value >> 31)
|
|
+}
|
|
+
|
|
+fn ranked(demand: &Demand, counts: [usize; 4], identity: Option<Id>) -> Vec<PortAnchorSide> {
|
|
let mut result = Vec::new();
|
|
for side in [Some(demand.preferred), demand.semantic]
|
|
.into_iter()
|
|
@@ -42,7 +57,12 @@ fn ranked(demand: &Demand, counts: [usize; 4]) -> Vec<PortAnchorSide> {
|
|
}
|
|
}
|
|
let mut remaining = SIDES;
|
|
- remaining.sort_by_key(|side| (counts[index(*side)], index(*side)));
|
|
+ if let Some(port) = identity {
|
|
+ remaining
|
|
+ .sort_by_key(|side| (std::cmp::Reverse(identity_score(port, *side)), index(*side)));
|
|
+ } else {
|
|
+ remaining.sort_by_key(|side| (counts[index(*side)], index(*side)));
|
|
+ }
|
|
for side in remaining {
|
|
if demand.clear[index(side)] && !result.contains(&side) {
|
|
result.push(side);
|
|
@@ -59,9 +79,10 @@ fn try_assign(
|
|
capacities: [usize; 4],
|
|
assigned: &mut [Option<PortAnchorSide>],
|
|
visited: &mut [bool; 4],
|
|
+ identities: Option<&[Id]>,
|
|
) -> bool {
|
|
let counts = counts(assigned);
|
|
- let sides = ranked(&demands[port], counts);
|
|
+ let sides = ranked(&demands[port], counts, identities.map(|ids| ids[port]));
|
|
for side in &sides {
|
|
let slot = index(*side);
|
|
if !visited[slot] && counts[slot] < capacities[slot] {
|
|
@@ -81,7 +102,7 @@ fn try_assign(
|
|
.filter_map(|(port, current)| (*current == Some(side)).then_some(port))
|
|
.collect();
|
|
for other in occupants {
|
|
- if try_assign(other, demands, capacities, assigned, visited) {
|
|
+ if try_assign(other, demands, capacities, assigned, visited, identities) {
|
|
assigned[port] = Some(side);
|
|
return true;
|
|
}
|
|
@@ -91,11 +112,27 @@ fn try_assign(
|
|
}
|
|
|
|
fn assign_with_capacities(demands: &[Demand], capacities: [usize; 4]) -> Vec<PortAnchorSide> {
|
|
+ assign_with_identities(demands, capacities, None)
|
|
+}
|
|
+
|
|
+fn assign_with_identities(
|
|
+ demands: &[Demand],
|
|
+ capacities: [usize; 4],
|
|
+ identities: Option<&[Id]>,
|
|
+) -> Vec<PortAnchorSide> {
|
|
+ assert!(identities.is_none_or(|ids| ids.len() == demands.len()));
|
|
let mut assigned = vec![None; demands.len()];
|
|
// Demands are in the core's stable semantic port-ID order. Manual anchors
|
|
// are deliberately absent: editing one must not resize owners or flip peers.
|
|
for port in 0..demands.len() {
|
|
- try_assign(port, demands, capacities, &mut assigned, &mut [false; 4]);
|
|
+ try_assign(
|
|
+ port,
|
|
+ demands,
|
|
+ capacities,
|
|
+ &mut assigned,
|
|
+ &mut [false; 4],
|
|
+ identities,
|
|
+ );
|
|
}
|
|
for (port, demand) in demands.iter().enumerate() {
|
|
if assigned[port].is_some() {
|
|
@@ -104,7 +141,7 @@ fn assign_with_capacities(demands: &[Demand], capacities: [usize; 4]) -> Vec<Por
|
|
let counts = counts(&assigned);
|
|
// Prefer a crowded clear exit to an available blocked one. The existing
|
|
// position allocator adapts spacing; no port or owner geometry is invented.
|
|
- let side = ranked(demand, counts)
|
|
+ let side = ranked(demand, counts, identities.map(|ids| ids[port]))
|
|
.into_iter()
|
|
.next()
|
|
.unwrap_or_else(|| {
|
|
@@ -130,6 +167,23 @@ pub(super) fn assign(node: &ProjectedNode, demands: &[Demand]) -> Vec<PortAnchor
|
|
)
|
|
}
|
|
|
|
+pub(super) fn assign_stable(
|
|
+ node: &ProjectedNode,
|
|
+ demands: &[Demand],
|
|
+ identities: &[Id],
|
|
+) -> Vec<PortAnchorSide> {
|
|
+ assign_with_identities(
|
|
+ demands,
|
|
+ [
|
|
+ capacity(node.size.height),
|
|
+ capacity(node.size.height),
|
|
+ capacity(node.size.width),
|
|
+ capacity(node.size.width),
|
|
+ ],
|
|
+ Some(identities),
|
|
+ )
|
|
+}
|
|
+
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
diff --git a/crates/oa-core/src/projection/port_layout.rs b/crates/oa-core/src/projection/port_layout.rs
|
|
index ba0d86897ed432efc466ed7af43dd52d010d08a0..555fa56b3829f357d41f3a94bbac520b690be9e7 100644
|
|
--- a/crates/oa-core/src/projection/port_layout.rs
|
|
+++ b/crates/oa-core/src/projection/port_layout.rs
|
|
@@ -232,17 +232,23 @@ pub(super) fn layout(
|
|
automatic_side: side,
|
|
manual: anchor.map(|a| a.offset),
|
|
peer,
|
|
- capacity: (policy == ExperimentalPortPolicy::CapacityAware).then(|| {
|
|
- capacity_experiment::Demand {
|
|
- preferred: side,
|
|
- semantic: port_anchor_side(port),
|
|
- clear: SIDES.map(clear),
|
|
- }
|
|
+ capacity: matches!(
|
|
+ policy,
|
|
+ ExperimentalPortPolicy::CapacityAware
|
|
+ | ExperimentalPortPolicy::CapacityAwareStableRanking
|
|
+ )
|
|
+ .then(|| capacity_experiment::Demand {
|
|
+ preferred: side,
|
|
+ semantic: port_anchor_side(port),
|
|
+ clear: SIDES.map(clear),
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
- if policy == ExperimentalPortPolicy::CapacityAware {
|
|
+ if matches!(
|
|
+ policy,
|
|
+ ExperimentalPortPolicy::CapacityAware | ExperimentalPortPolicy::CapacityAwareStableRanking
|
|
+ ) {
|
|
for node in nodes.iter() {
|
|
let Some(choices) = choices.get_mut(&node.id) else {
|
|
continue;
|
|
@@ -251,7 +257,12 @@ pub(super) fn layout(
|
|
.iter()
|
|
.map(|choice| choice.capacity.clone().unwrap())
|
|
.collect();
|
|
- let sides = capacity_experiment::assign(node, &demands);
|
|
+ let sides = if policy == ExperimentalPortPolicy::CapacityAwareStableRanking {
|
|
+ let identities: Vec<_> = choices.iter().map(|choice| choice.port).collect();
|
|
+ capacity_experiment::assign_stable(node, &demands, &identities)
|
|
+ } else {
|
|
+ capacity_experiment::assign(node, &demands)
|
|
+ };
|
|
for (choice, side) in choices.iter_mut().zip(sides) {
|
|
choice.automatic_side = side;
|
|
if choice.manual.is_none() {
|
|
diff --git a/crates/oa-core/tests/experimental_port_policy_falsifiers.rs b/crates/oa-core/tests/experimental_port_policy_falsifiers.rs
|
|
index ef6d197b0f397ce4c976859adabd62c46cbcb0da..0a31254a47fc151a723e21a372f1023d09ef83c0 100644
|
|
--- a/crates/oa-core/tests/experimental_port_policy_falsifiers.rs
|
|
+++ b/crates/oa-core/tests/experimental_port_policy_falsifiers.rs
|
|
@@ -402,3 +402,184 @@ fn capacity_spillover_does_not_add_a_blocked_mandatory_stub_above_the_owner() {
|
|
"Prospective exit-clearance criterion failed: {violations:?}"
|
|
);
|
|
}
|
|
+
|
|
+fn fixed_port_identity_set(mut f: Fixture, set: usize) -> Fixture {
|
|
+ if set == 0 {
|
|
+ return f;
|
|
+ }
|
|
+ // Two declared, reproducible UUID sets; not random draws or selected after
|
|
+ // observing layout results. Only port identities change, not topology.
|
|
+ let replacements: Vec<_> = (1..=f.ports.len())
|
|
+ .map(|n| {
|
|
+ let low = match set {
|
|
+ 1 => (n as u64).wrapping_mul(0xd134_2543_de82_ef95),
|
|
+ 2 => (n as u64)
|
|
+ .wrapping_mul(0x9e37_79b9_7f4a_7c15)
|
|
+ .rotate_left(23),
|
|
+ _ => unreachable!(),
|
|
+ } & 0x0000_ffff_ffff_ffff;
|
|
+ let prefix = if set == 1 {
|
|
+ 0x12345678_9abc_4000_8000_000000000000
|
|
+ } else {
|
|
+ 0xfedcba98_7654_4000_8000_000000000000
|
|
+ };
|
|
+ Id::from_u128(prefix | u128::from(low))
|
|
+ })
|
|
+ .collect();
|
|
+ assert_eq!(
|
|
+ replacements
|
|
+ .iter()
|
|
+ .collect::<std::collections::BTreeSet<_>>()
|
|
+ .len(),
|
|
+ f.ports.len()
|
|
+ );
|
|
+ let commands: Vec<_> = f
|
|
+ .ports
|
|
+ .iter()
|
|
+ .zip(&replacements)
|
|
+ .map(|(old, new)| Command::Reidentify(*old, *new))
|
|
+ .collect();
|
|
+ f.p = execute_batch(&f.p, &commands).unwrap();
|
|
+ f.ports = replacements;
|
|
+ validate_candidate(&f.p).unwrap();
|
|
+ assert_eq!(language::parse(&language::format(&f.p)).unwrap(), f.p);
|
|
+ f
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn stable_fallback_ranking_bounds_threshold_churn_across_three_identity_sets_and_reverse() {
|
|
+ let stable = ExperimentalPortPolicy::CapacityAwareStableRanking;
|
|
+ let mut violations = Vec::new();
|
|
+ for set in 0..3 {
|
|
+ for policy in [ExperimentalPortPolicy::CapacityAware, stable] {
|
|
+ let mut f = fixed_port_identity_set(right_peer(20, 259.9, false), set);
|
|
+ let mut views = Vec::new();
|
|
+ for (frame, height) in [259.9, 260., 260.1, 260., 259.9].into_iter().enumerate() {
|
|
+ f.p.diagrams
|
|
+ .get_mut(&f.d)
|
|
+ .unwrap()
|
|
+ .nodes
|
|
+ .get_mut(&f.owner)
|
|
+ .unwrap()
|
|
+ .size
|
|
+ .height = height;
|
|
+ let original = f.p.clone();
|
|
+ let view = project(&f, policy);
|
|
+ let label = format!("stable-threshold-set{set}-{policy:?}-frame{frame}-{height}");
|
|
+ measure(&f, &view, &label);
|
|
+ assert_eq!(f.p, original);
|
|
+ assert_eq!(owner(&f, &view).size, size(224., height));
|
|
+ if policy == stable && gap(&f, &view) < 27.99 {
|
|
+ violations.push(format!(
|
|
+ "{label}: avoidable sub-nominal gap {}",
|
|
+ gap(&f, &view)
|
|
+ ));
|
|
+ }
|
|
+ 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!(
|
|
+ "stable threshold set={set} policy={policy:?} changes={flips:?}; prediction original=[12,0,0,12], stable<=1 each (unverified)"
|
|
+ );
|
|
+ assert_eq!(
|
|
+ views[0], views[4],
|
|
+ "Same rectangle must reproject identically after reversal"
|
|
+ );
|
|
+ assert_eq!(views[1], views[3]);
|
|
+ if policy == stable && flips.iter().any(|n| *n > 1) {
|
|
+ violations.push(format!("set={set} stable side changes {flips:?}; same bounded criterion across every UUID set"));
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+ assert!(
|
|
+ violations.is_empty(),
|
|
+ "Prospective stable-ranking threshold criterion failed: {violations:?}"
|
|
+ );
|
|
+}
|
|
+
|
|
+#[test]
|
|
+fn stable_fallback_ranking_still_needs_real_exit_clearance_including_near_corner() {
|
|
+ let stable = ExperimentalPortPolicy::CapacityAwareStableRanking;
|
|
+ let mut violations = Vec::new();
|
|
+ for corner in [false, true] {
|
|
+ let mut f = right_peer(8, 160., true);
|
|
+ if corner {
|
|
+ let obstacle =
|
|
+ f.p.diagrams
|
|
+ .get_mut(&f.d)
|
|
+ .unwrap()
|
|
+ .nodes
|
|
+ .get_mut(&id(42))
|
|
+ .unwrap();
|
|
+ obstacle.position = pos(230., -120.);
|
|
+ obstacle.size = size(184., 132.);
|
|
+ }
|
|
+ validate_candidate(&f.p).unwrap();
|
|
+ assert_eq!(language::parse(&language::format(&f.p)).unwrap(), f.p);
|
|
+ let original = f.p.clone();
|
|
+ for policy in POLICIES.into_iter().chain([stable]) {
|
|
+ let view = project(&f, policy);
|
|
+ let label = format!("stable-obstacle-corner{corner}-{policy:?}");
|
|
+ let routed = measure(&f, &view, &label);
|
|
+ let blocked = blocked_stubs(&view);
|
|
+ if policy != ExperimentalPortPolicy::Current {
|
|
+ assert_eq!(owner(&f, &view).size, size(224., 160.));
|
|
+ }
|
|
+ if corner {
|
|
+ let obstacle = view.nodes.iter().find(|n| n.id == id(42)).unwrap();
|
|
+ assert_eq!(
|
|
+ (obstacle.position, obstacle.size),
|
|
+ (pos(230., -120.), size(184., 132.))
|
|
+ );
|
|
+ let midpoint = ProjectedPort {
|
|
+ element: f.ports[0],
|
|
+ owner: f.owner,
|
|
+ side: PortAnchorSide::Right,
|
|
+ position: pos(224., owner(&f, &view).size.height / 2.),
|
|
+ };
|
|
+ assert!(view.nodes.iter().all(|node| !stub_hits(&midpoint, node)));
|
|
+ // Independent geometric witness only: five legal same-side
|
|
+ // coordinates exist. No fake ports are passed to the projector
|
|
+ // or router. The actual mode must produce its own clear layout.
|
|
+ let coordinates = [24.01_f32, 53.5075, 83.005, 112.5025, 142.];
|
|
+ assert!(coordinates.windows(2).all(|p| p[1] - p[0] >= 28.));
|
|
+ for y in coordinates {
|
|
+ let witness = ProjectedPort {
|
|
+ position: pos(224., y),
|
|
+ ..midpoint.clone()
|
|
+ };
|
|
+ assert!(view.nodes.iter().all(|node| !stub_hits(&witness, node)));
|
|
+ }
|
|
+ println!(
|
|
+ "{label}: actual Right midpoint {:?} clear; independent five-position witness={coordinates:?}; actual blocked={blocked:?}",
|
|
+ midpoint.position
|
|
+ );
|
|
+ }
|
|
+ let warned: Vec<_> = routed
|
|
+ .edges
|
|
+ .values()
|
|
+ .filter(|e| !e.warnings.is_empty())
|
|
+ .map(|e| e.id)
|
|
+ .collect();
|
|
+ println!(
|
|
+ "{label}: candidate attribution only; midpoint/placement unchanged, warnings={warned:?}"
|
|
+ );
|
|
+ if policy == stable && (!blocked.is_empty() || !warned.is_empty()) {
|
|
+ violations.push(format!("{label}: blocked={blocked:?}, warned={warned:?}"));
|
|
+ }
|
|
+ assert_eq!(f.p, original);
|
|
+ }
|
|
+ }
|
|
+ assert!(
|
|
+ violations.is_empty(),
|
|
+ "Prospective stable-ranking real-exit criterion failed: {violations:?}"
|
|
+ );
|
|
+}
|