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]) -> [usize; 4] { result } -fn ranked(demand: &Demand, counts: [usize; 4]) -> Vec { +// 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) -> Vec { 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 { } } 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], 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 { + assign_with_identities(demands, capacities, None) +} + +fn assign_with_identities( + demands: &[Demand], + capacities: [usize; 4], + identities: Option<&[Id]>, +) -> Vec { + 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 Vec Vec { + 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::>() + .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:?}" + ); +}