1
0
Fork 0
forked from wry/wry

Explain multiphase planning decisions

This commit is contained in:
atagen 2026-05-21 21:10:27 +10:00
parent 0b6da9d8e0
commit 511e188d16
2 changed files with 398 additions and 62 deletions

View file

@ -242,6 +242,9 @@ Current pure planner status:
It distinguishes request validation errors, missing patterns, shrink-bound
rejections, invalid phase steps, and exact validation failures such as stale
starts or phase overlap.
- Multiphase planning also has an explained-plan entry point. Accepted plans
report the deterministic strategy, phase reasons, participating nodes, and
validation result; rejected plans report every attempted strategy and failure.
- Planner tests now include a deterministic split-tree generator. It builds
valid weighted tiling layouts, derives leaf hierarchy metadata, mutates them
through supported transitions, and runs the real planner plus exact validator.
@ -254,6 +257,7 @@ Tests:
- nested containers do not produce simultaneous cross-axis motion
- interruption restarts only affected phase groups
- reversing direction produces equivalent motion in reverse
- accepted and rejected plans expose deterministic strategy explanations
- child waits for parent/container-space phases when moving upward into a
toplevel peer position
- mono-mode tab switches do not animate, while entering/exiting mono can animate

View file

@ -120,6 +120,75 @@ pub struct MultiphasePlan {
pub phases: Vec<MultiphasePhase>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePlanned {
pub plan: MultiphasePlan,
pub explanation: MultiphasePlanExplanation,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePlanExplanation {
pub strategy: PlanStrategy,
pub phases: Vec<PhaseExplanation>,
pub validation: ValidationExplanation,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PhaseExplanation {
pub action: PhaseAction,
pub reason: PhaseReason,
pub nodes: Vec<NodeId>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct ValidationExplanation {
pub continuous_overlap_passed: bool,
pub final_rects_matched: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PlanStrategy {
NoOp,
SingleAction,
HierarchyOrderedScales,
SwapLanes { axis: PhaseAxis },
SpaceThenOrthogonalGrowth { axis: PhaseAxis },
ReversedForwardPlan { original: Box<PlanStrategy> },
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum PlanDirection {
Forward,
Reverse,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RejectedStrategy {
pub direction: PlanDirection,
pub strategy: PlanStrategy,
pub reason: MultiphasePlanFailure,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum PhaseReason {
SingleAction,
SameAxisRedistribution,
ShrinkIntoLanes {
lane_axis: PhaseAxis,
},
MoveThroughFreedSpace,
GrowOutOfLanes,
CreateSpaceForAscendingChild,
MoveAscendingChildAfterSpaceExists,
OrthogonalGrowthAfterMove,
ParentAxisBeforeChildAxis {
parent_axis: PhaseAxis,
parent_depth: u16,
child_axis: PhaseAxis,
child_depth: u16,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePhase {
pub action: PhaseAction,
@ -161,10 +230,11 @@ pub enum MultiphaseError {
NoPlan,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MultiphasePlanDiagnostic {
pub forward: MultiphasePlanFailure,
pub reverse: Option<MultiphasePlanFailure>,
pub attempted: Vec<RejectedStrategy>,
}
impl MultiphasePlanDiagnostic {
@ -176,6 +246,15 @@ impl MultiphasePlanDiagnostic {
}
}
impl ValidationExplanation {
fn passed() -> Self {
Self {
continuous_overlap_passed: true,
final_rects_matched: true,
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum MultiphasePlanFailure {
Request(MultiphaseError),
@ -201,6 +280,12 @@ pub enum MultiphaseValidationError {
FinalMismatch { node_id: NodeId },
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct PlanForwardFailure {
reason: MultiphasePlanFailure,
attempted: Vec<RejectedStrategy>,
}
pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result<MultiphasePlan, MultiphaseError> {
plan_no_overlap_with_diagnostics(request).map_err(|diagnostic| diagnostic.legacy_error())
}
@ -208,10 +293,17 @@ pub fn plan_no_overlap(request: &MultiphaseRequest) -> Result<MultiphasePlan, Mu
pub fn plan_no_overlap_with_diagnostics(
request: &MultiphaseRequest,
) -> Result<MultiphasePlan, MultiphasePlanDiagnostic> {
plan_no_overlap_explained(request).map(|planned| planned.plan)
}
pub fn plan_no_overlap_explained(
request: &MultiphaseRequest,
) -> Result<MultiphasePlanned, MultiphasePlanDiagnostic> {
if let Err(error) = validate_request(request) {
return Err(MultiphasePlanDiagnostic {
forward: MultiphasePlanFailure::Request(error),
reverse: None,
attempted: vec![],
});
}
if request
@ -219,25 +311,38 @@ pub fn plan_no_overlap_with_diagnostics(
.iter()
.all(|window| window.from == window.to)
{
return Ok(MultiphasePlan { phases: vec![] });
return Ok(MultiphasePlanned {
plan: MultiphasePlan { phases: vec![] },
explanation: MultiphasePlanExplanation {
strategy: PlanStrategy::NoOp,
phases: vec![],
validation: ValidationExplanation::passed(),
},
});
}
if let Some(failure) = target_shrink_bound_failure(request) {
return Err(MultiphasePlanDiagnostic {
forward: failure,
reverse: None,
attempted: vec![],
});
}
let forward = match plan_forward(request) {
let forward = match plan_forward(request, PlanDirection::Forward) {
Ok(plan) => return Ok(plan),
Err(error) => error,
};
let reversed = reverse_request(request);
match plan_forward(&reversed) {
Ok(plan) => Ok(reverse_plan(plan)),
Err(reverse) => Err(MultiphasePlanDiagnostic {
forward,
reverse: Some(reverse),
}),
match plan_forward(&reversed, PlanDirection::Reverse) {
Ok(plan) => Ok(reverse_planned(plan)),
Err(reverse) => {
let mut attempted = forward.attempted;
attempted.extend(reverse.attempted);
Err(MultiphasePlanDiagnostic {
forward: forward.reason,
reverse: Some(reverse.reason),
attempted,
})
}
}
}
@ -313,46 +418,89 @@ fn target_shrink_bound_failure(request: &MultiphaseRequest) -> Option<Multiphase
None
}
fn plan_forward(request: &MultiphaseRequest) -> Result<MultiphasePlan, MultiphasePlanFailure> {
fn plan_forward(
request: &MultiphaseRequest,
direction: PlanDirection,
) -> Result<MultiphasePlanned, PlanForwardFailure> {
let mut rejection = None;
let mut attempted = vec![];
match plan_single_action_phase(request) {
Ok(plan) => return Ok(plan),
Err(MultiphasePlanFailure::NoPattern) => {}
Err(error) => {
rejection.get_or_insert(error);
record_rejection(&mut attempted, direction, PlanStrategy::SingleAction, error);
if error != MultiphasePlanFailure::NoPattern {
rejection.get_or_insert(error);
}
}
}
match plan_hierarchy_ordered_axis_scales(request) {
Ok(plan) => return Ok(plan),
Err(MultiphasePlanFailure::NoPattern) => {}
Err(error) => {
rejection.get_or_insert(error);
record_rejection(
&mut attempted,
direction,
PlanStrategy::HierarchyOrderedScales,
error,
);
if error != MultiphasePlanFailure::NoPattern {
rejection.get_or_insert(error);
}
}
}
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
match plan_axis_crossing_lanes(request, axis) {
Ok(plan) => return Ok(plan),
Err(MultiphasePlanFailure::NoPattern) => {}
Err(error) => {
rejection.get_or_insert(error);
record_rejection(
&mut attempted,
direction,
PlanStrategy::SwapLanes { axis },
error,
);
if error != MultiphasePlanFailure::NoPattern {
rejection.get_or_insert(error);
}
}
}
}
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);
record_rejection(
&mut attempted,
direction,
PlanStrategy::SpaceThenOrthogonalGrowth { axis },
error,
);
if error != MultiphasePlanFailure::NoPattern {
rejection.get_or_insert(error);
}
}
}
}
Err(rejection.unwrap_or(MultiphasePlanFailure::NoPattern))
Err(PlanForwardFailure {
reason: rejection.unwrap_or(MultiphasePlanFailure::NoPattern),
attempted,
})
}
fn record_rejection(
attempted: &mut Vec<RejectedStrategy>,
direction: PlanDirection,
strategy: PlanStrategy,
reason: MultiphasePlanFailure,
) {
attempted.push(RejectedStrategy {
direction,
strategy,
reason,
});
}
fn plan_single_action_phase(
request: &MultiphaseRequest,
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
let mut action = None;
let mut steps = vec![];
for window in &request.windows {
@ -389,12 +537,21 @@ fn plan_single_action_phase(
let Some(action) = action else {
return Err(MultiphasePlanFailure::NoPattern);
};
build_validated_plan(request, [(action.kind, action.axis, steps)])
build_validated_plan(
request,
PlanStrategy::SingleAction,
[phase_draft(
action.kind,
action.axis,
steps,
single_action_reason(action),
)],
)
}
fn plan_hierarchy_ordered_axis_scales(
request: &MultiphaseRequest,
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
let mut changed_axes = vec![];
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
if request
@ -408,7 +565,7 @@ fn plan_hierarchy_ordered_axis_scales(
let [first_axis, second_axis] = changed_axes
.try_into()
.map_err(|_| MultiphasePlanFailure::NoPattern)?;
let axes = hierarchy_scale_axis_order(request, first_axis, second_axis)
let order = hierarchy_scale_axis_order(request, first_axis, second_axis)
.ok_or(MultiphasePlanFailure::NoPattern)?;
let mut current: Vec<_> = request
.windows
@ -416,7 +573,13 @@ fn plan_hierarchy_ordered_axis_scales(
.map(|window| (window.node_id, window.from))
.collect();
let mut phases = vec![];
for axis in axes {
let reason = PhaseReason::ParentAxisBeforeChildAxis {
parent_axis: order.axes[0],
parent_depth: order.depths[0],
child_axis: order.axes[1],
child_depth: order.depths[1],
};
for axis in order.axes {
let mut steps = vec![];
for window in &request.windows {
let (_, rect) = current
@ -445,28 +608,44 @@ fn plan_hierarchy_ordered_axis_scales(
if steps.is_empty() {
return Err(MultiphasePlanFailure::NoPattern);
}
phases.push((PhaseKind::Scale, axis, steps));
phases.push(phase_draft(PhaseKind::Scale, axis, steps, reason));
}
let [first, second] = phases
.try_into()
.map_err(|_| MultiphasePlanFailure::NoPattern)?;
build_validated_plan(request, [first, second])
build_validated_plan(
request,
PlanStrategy::HierarchyOrderedScales,
[first, second],
)
}
fn hierarchy_scale_axis_order(
request: &MultiphaseRequest,
first_axis: PhaseAxis,
second_axis: PhaseAxis,
) -> Option<[PhaseAxis; 2]> {
) -> Option<HierarchyScaleAxisOrder> {
let first_priority = hierarchy_axis_priority(request, first_axis)?;
let second_priority = hierarchy_axis_priority(request, second_axis)?;
match first_priority.cmp(&second_priority) {
std::cmp::Ordering::Less => Some([first_axis, second_axis]),
std::cmp::Ordering::Greater => Some([second_axis, first_axis]),
std::cmp::Ordering::Less => Some(HierarchyScaleAxisOrder {
axes: [first_axis, second_axis],
depths: [first_priority, second_priority],
}),
std::cmp::Ordering::Greater => Some(HierarchyScaleAxisOrder {
axes: [second_axis, first_axis],
depths: [second_priority, first_priority],
}),
std::cmp::Ordering::Equal => None,
}
}
#[derive(Copy, Clone)]
struct HierarchyScaleAxisOrder {
axes: [PhaseAxis; 2],
depths: [u16; 2],
}
fn hierarchy_axis_priority(request: &MultiphaseRequest, axis: PhaseAxis) -> Option<u16> {
request
.windows
@ -485,7 +664,7 @@ fn hierarchy_axis_priority(request: &MultiphaseRequest, axis: PhaseAxis) -> Opti
fn plan_axis_crossing_lanes(
request: &MultiphaseRequest,
axis: PhaseAxis,
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
if request.windows.len() != 2 {
return Err(MultiphasePlanFailure::NoPattern);
}
@ -551,10 +730,28 @@ fn plan_axis_crossing_lanes(
}
build_validated_plan(
request,
PlanStrategy::SwapLanes { axis },
[
(PhaseKind::Scale, axis.other(), phase1),
(PhaseKind::Move, axis, phase2),
(PhaseKind::Scale, axis.other(), phase3),
phase_draft(
PhaseKind::Scale,
axis.other(),
phase1,
PhaseReason::ShrinkIntoLanes {
lane_axis: axis.other(),
},
),
phase_draft(
PhaseKind::Move,
axis,
phase2,
PhaseReason::MoveThroughFreedSpace,
),
phase_draft(
PhaseKind::Scale,
axis.other(),
phase3,
PhaseReason::GrowOutOfLanes,
),
],
)
}
@ -571,7 +768,7 @@ fn lane_index_for_direction(window: MultiphaseWindow, axis: PhaseAxis) -> Option
fn plan_space_then_orthogonal_growth(
request: &MultiphaseRequest,
axis: PhaseAxis,
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
if request.windows.len() < 2 {
return Err(MultiphasePlanFailure::NoPattern);
}
@ -631,24 +828,71 @@ fn plan_space_then_orthogonal_growth(
}
build_validated_plan(
request,
PlanStrategy::SpaceThenOrthogonalGrowth { axis },
[
(PhaseKind::Scale, axis, phase1),
(PhaseKind::Move, axis, phase2),
(PhaseKind::Scale, orth_axis, phase3),
phase_draft(
PhaseKind::Scale,
axis,
phase1,
PhaseReason::CreateSpaceForAscendingChild,
),
phase_draft(
PhaseKind::Move,
axis,
phase2,
PhaseReason::MoveAscendingChildAfterSpaceExists,
),
phase_draft(
PhaseKind::Scale,
orth_axis,
phase3,
PhaseReason::OrthogonalGrowthAfterMove,
),
],
)
}
struct MultiphasePhaseDraft {
action: PhaseAction,
steps: Vec<MultiphaseStep>,
reason: PhaseReason,
}
fn phase_draft(
kind: PhaseKind,
axis: PhaseAxis,
steps: Vec<MultiphaseStep>,
reason: PhaseReason,
) -> MultiphasePhaseDraft {
MultiphasePhaseDraft {
action: PhaseAction { kind, axis },
steps,
reason,
}
}
fn build_validated_plan<const N: usize>(
request: &MultiphaseRequest,
phases: [(PhaseKind, PhaseAxis, Vec<MultiphaseStep>); N],
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
strategy: PlanStrategy,
phases: [MultiphasePhaseDraft; N],
) -> Result<MultiphasePlanned, MultiphasePlanFailure> {
let mut explanations = vec![];
let phases: Vec<_> = phases
.into_iter()
.filter_map(|(kind, axis, steps)| {
(!steps.is_empty()).then_some(MultiphasePhase {
action: PhaseAction { kind, axis },
steps,
.filter_map(|draft| {
if draft.steps.is_empty() {
return None;
}
let mut nodes: Vec<_> = draft.steps.iter().map(|step| step.node_id).collect();
nodes.sort_by_key(|node_id| node_id.0);
explanations.push(PhaseExplanation {
action: draft.action,
reason: draft.reason,
nodes,
});
Some(MultiphasePhase {
action: draft.action,
steps: draft.steps,
})
})
.collect();
@ -664,7 +908,14 @@ fn build_validated_plan<const N: usize>(
}
let plan = MultiphasePlan { phases };
validate_plan_continuous_diagnostic(request, &plan)
.map(|_| plan)
.map(|_| MultiphasePlanned {
plan,
explanation: MultiphasePlanExplanation {
strategy,
phases: explanations,
validation: ValidationExplanation::passed(),
},
})
.map_err(MultiphasePlanFailure::Validation)
}
@ -894,6 +1145,13 @@ fn classify_step(step: MultiphaseStep) -> Option<PhaseAction> {
}
}
fn single_action_reason(action: PhaseAction) -> PhaseReason {
match action.kind {
PhaseKind::Move => PhaseReason::SingleAction,
PhaseKind::Scale => PhaseReason::SameAxisRedistribution,
}
}
fn reverse_request(request: &MultiphaseRequest) -> MultiphaseRequest {
MultiphaseRequest {
bounds: request.bounds,
@ -932,6 +1190,21 @@ fn reverse_plan(plan: MultiphasePlan) -> MultiphasePlan {
}
}
fn reverse_planned(planned: MultiphasePlanned) -> MultiphasePlanned {
let mut phases = planned.explanation.phases;
phases.reverse();
MultiphasePlanned {
plan: reverse_plan(planned.plan),
explanation: MultiphasePlanExplanation {
strategy: PlanStrategy::ReversedForwardPlan {
original: Box::new(planned.explanation.strategy),
},
phases,
validation: planned.explanation.validation,
},
}
}
fn overlaps(rects: impl IntoIterator<Item = Rect>) -> bool {
let rects: Vec<_> = rects.into_iter().collect();
for (idx, rect) in rects.iter().enumerate() {
@ -1240,9 +1513,10 @@ mod tests {
hierarchy: Default::default(),
},
]);
let plan = plan_no_overlap(&req).unwrap();
let planned = plan_no_overlap_explained(&req).unwrap();
let plan = &planned.plan;
assert_eq!(
actions(&plan),
actions(plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
@ -1260,7 +1534,29 @@ mod tests {
);
assert_eq!(plan.phases[0].steps[0].to, rect(0, 0, 100, 50));
assert_eq!(plan.phases[0].steps[1].to, rect(100, 50, 200, 100));
assert!(validate_plan_continuous(&req, &plan));
assert_eq!(
planned.explanation.strategy,
PlanStrategy::SwapLanes {
axis: PhaseAxis::Horizontal
}
);
assert_eq!(
planned
.explanation
.phases
.iter()
.map(|phase| phase.reason)
.collect::<Vec<_>>(),
vec![
PhaseReason::ShrinkIntoLanes {
lane_axis: PhaseAxis::Vertical
},
PhaseReason::MoveThroughFreedSpace,
PhaseReason::GrowOutOfLanes,
]
);
assert_eq!(planned.explanation.phases[0].nodes, vec![id(1), id(2)]);
assert!(validate_plan_continuous(&req, plan));
}
#[test]
@ -1410,9 +1706,10 @@ mod tests {
],
);
let req = generated_request(&old, &new, rect(0, 0, 400, 100));
let plan = plan_no_overlap(&req).unwrap();
let planned = plan_no_overlap_explained(&req).unwrap();
let plan = &planned.plan;
assert_eq!(
actions(&plan),
actions(plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
@ -1424,10 +1721,32 @@ mod tests {
},
]
);
assert_eq!(step_to(&plan, 0, id(1)), rect(0, 0, 100, 100));
assert_eq!(step_to(&plan, 0, id(2)), rect(100, 0, 400, 50));
assert_eq!(step_to(&plan, 0, id(3)), rect(100, 50, 400, 100));
assert!(validate_plan_continuous(&req, &plan));
assert_eq!(step_to(plan, 0, id(1)), rect(0, 0, 100, 100));
assert_eq!(step_to(plan, 0, id(2)), rect(100, 0, 400, 50));
assert_eq!(step_to(plan, 0, id(3)), rect(100, 50, 400, 100));
assert_eq!(
planned.explanation.strategy,
PlanStrategy::HierarchyOrderedScales
);
assert_eq!(
planned.explanation.phases[0].reason,
PhaseReason::ParentAxisBeforeChildAxis {
parent_axis: PhaseAxis::Horizontal,
parent_depth: 1,
child_axis: PhaseAxis::Vertical,
child_depth: 2,
}
);
assert_eq!(
planned.explanation.phases[0].nodes,
vec![id(1), id(2), id(3)]
);
assert_eq!(planned.explanation.phases[1].nodes, vec![id(2), id(3)]);
assert_eq!(
planned.explanation.validation,
ValidationExplanation::passed()
);
assert!(validate_plan_continuous(&req, plan));
}
#[test]
@ -1549,9 +1868,10 @@ mod tests {
hierarchy: Default::default(),
},
]);
let plan = plan_no_overlap(&req).unwrap();
let planned = plan_no_overlap_explained(&req).unwrap();
let plan = &planned.plan;
assert_eq!(
actions(&plan),
actions(plan),
vec![
PhaseAction {
kind: PhaseKind::Scale,
@ -1567,7 +1887,15 @@ mod tests {
},
]
);
assert!(validate_plan_continuous(&req, &plan));
assert_eq!(
planned.explanation.strategy,
PlanStrategy::ReversedForwardPlan {
original: Box::new(PlanStrategy::SpaceThenOrthogonalGrowth {
axis: PhaseAxis::Horizontal
})
}
);
assert!(validate_plan_continuous(&req, plan));
}
#[test]
@ -1670,12 +1998,16 @@ 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),
}
let diagnostic = plan_no_overlap_with_diagnostics(&req).unwrap_err();
assert_eq!(diagnostic.forward, MultiphasePlanFailure::NoPattern);
assert_eq!(diagnostic.reverse, Some(MultiphasePlanFailure::NoPattern));
assert!(
diagnostic
.attempted
.iter()
.any(|attempt| attempt.direction == PlanDirection::Forward
&& attempt.strategy == PlanStrategy::SingleAction
&& attempt.reason == MultiphasePlanFailure::NoPattern)
);
}