1
0
Fork 0
forked from wry/wry

Report multiphase planning diagnostics

This commit is contained in:
atagen 2026-05-21 19:59:19 +10:00
parent 90c00bcdf3
commit cc898590d2
3 changed files with 230 additions and 56 deletions

View file

@ -233,6 +233,10 @@ Current pure planner status:
parent, depth, sibling index, split axis, mono state, and transition kind.
The current planner records this information but does not yet use it to order
nested-container phases.
- Multiphase planning has a diagnostic entry point used by live fallback logs.
It distinguishes request validation errors, missing patterns, shrink-bound
rejections, invalid phase steps, and exact validation failures such as stale
starts or phase overlap.
Tests:

View file

@ -159,8 +159,59 @@ pub enum MultiphaseError {
NoPlan,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePlanDiagnostic {
pub forward: MultiphasePlanFailure,
pub reverse: Option<MultiphasePlanFailure>,
}
impl MultiphasePlanDiagnostic {
fn legacy_error(self) -> MultiphaseError {
match self.forward {
MultiphasePlanFailure::Request(error) => error,
_ => MultiphaseError::NoPlan,
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum MultiphasePlanFailure {
Request(MultiphaseError),
NoPattern,
ShrinkBound {
axis: PhaseAxis,
available: i32,
required: i32,
},
InvalidPhaseStep {
action: PhaseAction,
node_id: NodeId,
},
Validation(MultiphaseValidationError),
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum MultiphaseValidationError {
DuplicatePhaseStep { phase: usize, node_id: NodeId },
UnknownPhaseStep { phase: usize, node_id: NodeId },
StaleStepStart { phase: usize, node_id: NodeId },
PhaseOverlap { phase: usize, a: NodeId, b: NodeId },
FinalMismatch { node_id: NodeId },
}
pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result<MultiphasePlan, MultiphaseError> {
validate_request(request)?;
plan_no_overlap_with_diagnostics(request).map_err(|diagnostic| diagnostic.legacy_error())
}
pub fn plan_no_overlap_with_diagnostics(
request: &MultiphaseRequest,
) -> Result<MultiphasePlan, MultiphasePlanDiagnostic> {
if let Err(error) = validate_request(request) {
return Err(MultiphasePlanDiagnostic {
forward: MultiphasePlanFailure::Request(error),
reverse: None,
});
}
if request
.windows
.iter()
@ -168,14 +219,18 @@ pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result<MultiphasePlan, Mu
{
return Ok(MultiphasePlan { phases: vec![] });
}
if let Some(plan) = plan_forward(request) {
return Ok(plan);
}
let forward = match plan_forward(request) {
Ok(plan) => return Ok(plan),
Err(error) => error,
};
let reversed = reverse_request(request);
if let Some(plan) = plan_forward(&reversed) {
return Ok(reverse_plan(plan));
match plan_forward(&reversed) {
Ok(plan) => Ok(reverse_plan(plan)),
Err(reverse) => Err(MultiphasePlanDiagnostic {
forward,
reverse: Some(reverse),
}),
}
Err(MultiphaseError::NoPlan)
}
pub(crate) fn partition_motion_groups(windows: &[MultiphaseWindow]) -> Vec<Vec<usize>> {
@ -228,33 +283,48 @@ fn validate_request(request: &MultiphaseRequest) -> Result<(), MultiphaseError>
Ok(())
}
fn plan_forward(request: &MultiphaseRequest) -> Option<MultiphasePlan> {
fn plan_forward(request: &MultiphaseRequest) -> Result<MultiphasePlan, MultiphasePlanFailure> {
let mut rejection = None;
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
if let Some(plan) = plan_axis_crossing_lanes(request, axis) {
return Some(plan);
match plan_axis_crossing_lanes(request, axis) {
Ok(plan) => return Ok(plan),
Err(MultiphasePlanFailure::NoPattern) => {}
Err(error) => {
rejection.get_or_insert(error);
}
}
}
plan_space_then_orthogonal_growth(request, PhaseAxis::Horizontal)
.or_else(|| plan_space_then_orthogonal_growth(request, PhaseAxis::Vertical))
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
match plan_space_then_orthogonal_growth(request, axis) {
Ok(plan) => return Ok(plan),
Err(MultiphasePlanFailure::NoPattern) => {}
Err(error) => {
rejection.get_or_insert(error);
}
}
}
Err(rejection.unwrap_or(MultiphasePlanFailure::NoPattern))
}
fn plan_axis_crossing_lanes(
request: &MultiphaseRequest,
axis: PhaseAxis,
) -> Option<MultiphasePlan> {
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
if request.windows.len() != 2 {
return None;
return Err(MultiphasePlanFailure::NoPattern);
}
let orth_min = request
.windows
.iter()
.map(|window| orth_start(window.from, axis))
.min()?;
.min()
.ok_or(MultiphasePlanFailure::NoPattern)?;
let orth_max = request
.windows
.iter()
.map(|window| orth_end(window.from, axis))
.max()?;
.max()
.ok_or(MultiphasePlanFailure::NoPattern)?;
if request.windows.iter().any(|window| {
main_size(window.from, axis) != main_size(window.to, axis)
|| orth_start(window.from, axis) != orth_min
@ -263,11 +333,16 @@ fn plan_axis_crossing_lanes(
|| orth_end(window.to, axis) != orth_max
|| main_start(window.from, axis) == main_start(window.to, axis)
}) {
return None;
return Err(MultiphasePlanFailure::NoPattern);
}
let lane_size = (orth_max - orth_min) / request.windows.len() as i32;
if lane_size < sane_min_size(orth_max - orth_min) {
return None;
let required = sane_min_size(orth_max - orth_min);
if lane_size < required {
return Err(MultiphasePlanFailure::ShrinkBound {
axis: axis.other(),
available: lane_size,
required,
});
}
let mut windows = request.windows.clone();
@ -275,7 +350,7 @@ fn plan_axis_crossing_lanes(
if windows.windows(2).any(|pair| {
lane_index_for_direction(pair[0], axis) == lane_index_for_direction(pair[1], axis)
}) {
return None;
return Err(MultiphasePlanFailure::NoPattern);
}
let mut phase1 = vec![];
let mut phase2 = vec![];
@ -320,9 +395,9 @@ fn lane_index_for_direction(window: MultiphaseWindow, axis: PhaseAxis) -> Option
fn plan_space_then_orthogonal_growth(
request: &MultiphaseRequest,
axis: PhaseAxis,
) -> Option<MultiphasePlan> {
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
if request.windows.len() < 2 {
return None;
return Err(MultiphasePlanFailure::NoPattern);
}
let orth_axis = axis.other();
let min_width = sane_min_size(request.bounds.width());
@ -331,8 +406,19 @@ fn plan_space_then_orthogonal_growth(
let mut phase2 = vec![];
let mut phase3 = vec![];
for window in &request.windows {
if window.to.width() < min_width || window.to.height() < min_height {
return None;
if window.to.width() < min_width {
return Err(MultiphasePlanFailure::ShrinkBound {
axis: PhaseAxis::Horizontal,
available: window.to.width(),
required: min_width,
});
}
if window.to.height() < min_height {
return Err(MultiphasePlanFailure::ShrinkBound {
axis: PhaseAxis::Vertical,
available: window.to.height(),
required: min_height,
});
}
let main_changes = main_start(window.from, axis) != main_start(window.to, axis)
|| main_end(window.from, axis) != main_end(window.to, axis);
@ -365,7 +451,7 @@ fn plan_space_then_orthogonal_growth(
}
}
if phase1.is_empty() || phase2.is_empty() || phase3.is_empty() {
return None;
return Err(MultiphasePlanFailure::NoPattern);
}
build_validated_plan(
request,
@ -380,7 +466,7 @@ fn plan_space_then_orthogonal_growth(
fn build_validated_plan<const N: usize>(
request: &MultiphaseRequest,
phases: [(PhaseKind, PhaseAxis, Vec<MultiphaseStep>); N],
) -> Option<MultiphasePlan> {
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
let phases: Vec<_> = phases
.into_iter()
.filter_map(|(kind, axis, steps)| {
@ -390,41 +476,58 @@ fn build_validated_plan<const N: usize>(
})
})
.collect();
if phases.iter().any(|phase| {
phase
.steps
.iter()
.any(|step| classify_step(*step) != Some(phase.action))
}) {
return None;
for phase in &phases {
for step in &phase.steps {
if classify_step(*step) != Some(phase.action) {
return Err(MultiphasePlanFailure::InvalidPhaseStep {
action: phase.action,
node_id: step.node_id,
});
}
}
}
let plan = MultiphasePlan { phases };
validate_plan_continuous(request, &plan).then_some(plan)
validate_plan_continuous_diagnostic(request, &plan)
.map(|_| plan)
.map_err(MultiphasePlanFailure::Validation)
}
fn validate_plan_continuous(request: &MultiphaseRequest, plan: &MultiphasePlan) -> bool {
validate_plan_continuous_diagnostic(request, plan).is_ok()
}
fn validate_plan_continuous_diagnostic(
request: &MultiphaseRequest,
plan: &MultiphasePlan,
) -> Result<(), MultiphaseValidationError> {
let mut current: Vec<_> = request
.windows
.iter()
.map(|window| (window.node_id, window.from))
.collect();
if overlaps(current.iter().map(|(_, rect)| *rect)) {
return false;
}
for phase in &plan.phases {
for (phase_idx, phase) in plan.phases.iter().enumerate() {
for (idx, step) in phase.steps.iter().enumerate() {
if phase.steps[..idx]
.iter()
.any(|prev| prev.node_id == step.node_id)
{
return false;
return Err(MultiphaseValidationError::DuplicatePhaseStep {
phase: phase_idx,
node_id: step.node_id,
});
}
let Some((_, rect)) = current.iter().find(|(node_id, _)| *node_id == step.node_id)
else {
return false;
return Err(MultiphaseValidationError::UnknownPhaseStep {
phase: phase_idx,
node_id: step.node_id,
});
};
if *rect != step.from {
return false;
return Err(MultiphaseValidationError::StaleStepStart {
phase: phase_idx,
node_id: step.node_id,
});
}
}
let motions: Vec<_> = current
@ -440,11 +543,16 @@ fn validate_plan_continuous(request: &MultiphaseRequest, plan: &MultiphasePlan)
})
.collect();
for (idx, motion) in motions.iter().enumerate() {
if motions[idx + 1..]
if let Some((other_idx, _)) = motions[idx + 1..]
.iter()
.any(|other| motions_overlap_during_phase(*motion, *other))
.enumerate()
.find(|(_, other)| motions_overlap_during_phase(*motion, **other))
{
return false;
return Err(MultiphaseValidationError::PhaseOverlap {
phase: phase_idx,
a: current[idx].0,
b: current[idx + 1 + other_idx].0,
});
}
}
for step in &phase.steps {
@ -454,16 +562,19 @@ fn validate_plan_continuous(request: &MultiphaseRequest, plan: &MultiphasePlan)
.unwrap();
*rect = step.to;
}
if overlaps(current.iter().map(|(_, rect)| *rect)) {
return false;
}
}
request.windows.iter().all(|window| {
current
for window in &request.windows {
if !current
.iter()
.find(|(node_id, _)| *node_id == window.node_id)
.is_some_and(|(_, rect)| *rect == window.to)
})
{
return Err(MultiphaseValidationError::FinalMismatch {
node_id: window.node_id,
});
}
}
Ok(())
}
#[derive(Copy, Clone)]
@ -1044,6 +1155,43 @@ mod tests {
hierarchy: Default::default(),
}]);
assert_eq!(plan_no_overlap(&req), Err(MultiphaseError::NoPlan));
assert_eq!(
plan_no_overlap_with_diagnostics(&req).unwrap_err(),
MultiphasePlanDiagnostic {
forward: MultiphasePlanFailure::NoPattern,
reverse: Some(MultiphasePlanFailure::NoPattern),
}
);
}
#[test]
fn diagnostics_report_shrink_bound_rejections() {
let req = MultiphaseRequest {
bounds: rect(0, 0, 400, 100),
windows: vec![
MultiphaseWindow {
node_id: id(1),
from: rect(0, 0, 200, 100),
to: rect(0, 0, 10, 100),
hierarchy: Default::default(),
},
MultiphaseWindow {
node_id: id(2),
from: rect(200, 0, 400, 100),
to: rect(10, 0, 400, 100),
hierarchy: Default::default(),
},
],
};
assert!(matches!(
plan_no_overlap_with_diagnostics(&req).unwrap_err().forward,
MultiphasePlanFailure::ShrinkBound {
axis: PhaseAxis::Horizontal,
available: 10,
required: 100,
}
));
}
#[test]
@ -1115,7 +1263,14 @@ mod tests {
}],
};
assert!(!validate_plan_continuous(&req, &plan));
assert_eq!(
validate_plan_continuous_diagnostic(&req, &plan),
Err(MultiphaseValidationError::PhaseOverlap {
phase: 0,
a: id(1),
b: id(2),
})
);
}
#[test]
@ -1173,7 +1328,13 @@ mod tests {
}],
};
assert!(!validate_plan_continuous(&req, &plan));
assert_eq!(
validate_plan_continuous_diagnostic(&req, &plan),
Err(MultiphaseValidationError::StaleStepStart {
phase: 0,
node_id: id(1),
})
);
}
#[test]

View file

@ -7,7 +7,7 @@ use {
expand_damage_rect,
multiphase::{
MultiphaseRequest, MultiphaseWindow, MultiphaseWindowHierarchy,
partition_motion_groups, plan_no_overlap,
partition_motion_groups, plan_no_overlap_with_diagnostics,
},
spawn_in_start_rect,
},
@ -1661,11 +1661,20 @@ impl State {
for window in &request_windows[1..] {
bounds = bounds.union(window.from).union(window.to);
}
let Ok(plan) = plan_no_overlap(&MultiphaseRequest {
let request = MultiphaseRequest {
bounds,
windows: request_windows,
}) else {
return false;
};
let plan = match plan_no_overlap_with_diagnostics(&request) {
Ok(plan) => plan,
Err(diagnostic) => {
log::debug!(
"falling back to linear layout animation for group {:?}: {:?}",
group,
diagnostic
);
return false;
}
};
if plan.phases.is_empty() {
return false;