Exercise planner with generated split trees
This commit is contained in:
parent
cc898590d2
commit
a770089b88
2 changed files with 329 additions and 0 deletions
|
|
@ -223,6 +223,8 @@ Current pure planner status:
|
||||||
- Stack extraction/return patterns are covered in both horizontal and vertical
|
- Stack extraction/return patterns are covered in both horizontal and vertical
|
||||||
orientations: peer/container space scales first, the extracted child moves
|
orientations: peer/container space scales first, the extracted child moves
|
||||||
only after space exists, and orthogonal growth happens in the final phase.
|
only after space exists, and orthogonal growth happens in the final phase.
|
||||||
|
- Same-axis size redistribution is handled as a single scale phase when the
|
||||||
|
exact validator proves adjacent windows stay non-overlapping.
|
||||||
- Every produced plan is checked analytically for overlap over the full duration
|
- Every produced plan is checked analytically for overlap over the full duration
|
||||||
of each phase before it is accepted. This solves the linear edge inequalities
|
of each phase before it is accepted. This solves the linear edge inequalities
|
||||||
for each pair of moving rectangles instead of relying on sampled frames.
|
for each pair of moving rectangles instead of relying on sampled frames.
|
||||||
|
|
@ -237,6 +239,9 @@ Current pure planner status:
|
||||||
It distinguishes request validation errors, missing patterns, shrink-bound
|
It distinguishes request validation errors, missing patterns, shrink-bound
|
||||||
rejections, invalid phase steps, and exact validation failures such as stale
|
rejections, invalid phase steps, and exact validation failures such as stale
|
||||||
starts or phase overlap.
|
starts or phase overlap.
|
||||||
|
- 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.
|
||||||
|
|
||||||
Tests:
|
Tests:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,12 @@ pub fn plan_no_overlap_with_diagnostics(
|
||||||
{
|
{
|
||||||
return Ok(MultiphasePlan { phases: vec![] });
|
return Ok(MultiphasePlan { phases: vec![] });
|
||||||
}
|
}
|
||||||
|
if let Some(failure) = target_shrink_bound_failure(request) {
|
||||||
|
return Err(MultiphasePlanDiagnostic {
|
||||||
|
forward: failure,
|
||||||
|
reverse: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
let forward = match plan_forward(request) {
|
let forward = match plan_forward(request) {
|
||||||
Ok(plan) => return Ok(plan),
|
Ok(plan) => return Ok(plan),
|
||||||
Err(error) => error,
|
Err(error) => error,
|
||||||
|
|
@ -283,8 +289,37 @@ fn validate_request(request: &MultiphaseRequest) -> Result<(), MultiphaseError>
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn target_shrink_bound_failure(request: &MultiphaseRequest) -> Option<MultiphasePlanFailure> {
|
||||||
|
let min_width = sane_min_size(request.bounds.width());
|
||||||
|
let min_height = sane_min_size(request.bounds.height());
|
||||||
|
for window in &request.windows {
|
||||||
|
if window.to.width() < min_width {
|
||||||
|
return Some(MultiphasePlanFailure::ShrinkBound {
|
||||||
|
axis: PhaseAxis::Horizontal,
|
||||||
|
available: window.to.width(),
|
||||||
|
required: min_width,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if window.to.height() < min_height {
|
||||||
|
return Some(MultiphasePlanFailure::ShrinkBound {
|
||||||
|
axis: PhaseAxis::Vertical,
|
||||||
|
available: window.to.height(),
|
||||||
|
required: min_height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn plan_forward(request: &MultiphaseRequest) -> Result<MultiphasePlan, MultiphasePlanFailure> {
|
fn plan_forward(request: &MultiphaseRequest) -> Result<MultiphasePlan, MultiphasePlanFailure> {
|
||||||
let mut rejection = None;
|
let mut rejection = None;
|
||||||
|
match plan_single_action_phase(request) {
|
||||||
|
Ok(plan) => return Ok(plan),
|
||||||
|
Err(MultiphasePlanFailure::NoPattern) => {}
|
||||||
|
Err(error) => {
|
||||||
|
rejection.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
|
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
|
||||||
match plan_axis_crossing_lanes(request, axis) {
|
match plan_axis_crossing_lanes(request, axis) {
|
||||||
Ok(plan) => return Ok(plan),
|
Ok(plan) => return Ok(plan),
|
||||||
|
|
@ -306,6 +341,48 @@ fn plan_forward(request: &MultiphaseRequest) -> Result<MultiphasePlan, Multiphas
|
||||||
Err(rejection.unwrap_or(MultiphasePlanFailure::NoPattern))
|
Err(rejection.unwrap_or(MultiphasePlanFailure::NoPattern))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn plan_single_action_phase(
|
||||||
|
request: &MultiphaseRequest,
|
||||||
|
) -> Result<MultiphasePlan, MultiphasePlanFailure> {
|
||||||
|
let mut action = None;
|
||||||
|
let mut steps = vec![];
|
||||||
|
for window in &request.windows {
|
||||||
|
if window.from == window.to {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let step = MultiphaseStep {
|
||||||
|
node_id: window.node_id,
|
||||||
|
from: window.from,
|
||||||
|
to: window.to,
|
||||||
|
};
|
||||||
|
let Some(step_action) = classify_step(step) else {
|
||||||
|
return Err(MultiphasePlanFailure::NoPattern);
|
||||||
|
};
|
||||||
|
if step_action.kind == PhaseKind::Scale {
|
||||||
|
let (available, required) = match step_action.axis {
|
||||||
|
PhaseAxis::Horizontal => (window.to.width(), sane_min_size(request.bounds.width())),
|
||||||
|
PhaseAxis::Vertical => (window.to.height(), sane_min_size(request.bounds.height())),
|
||||||
|
};
|
||||||
|
if available < required {
|
||||||
|
return Err(MultiphasePlanFailure::ShrinkBound {
|
||||||
|
axis: step_action.axis,
|
||||||
|
available,
|
||||||
|
required,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if action.is_some_and(|action| action != step_action) {
|
||||||
|
return Err(MultiphasePlanFailure::NoPattern);
|
||||||
|
}
|
||||||
|
action = Some(step_action);
|
||||||
|
steps.push(step);
|
||||||
|
}
|
||||||
|
let Some(action) = action else {
|
||||||
|
return Err(MultiphasePlanFailure::NoPattern);
|
||||||
|
};
|
||||||
|
build_validated_plan(request, [(action.kind, action.axis, steps)])
|
||||||
|
}
|
||||||
|
|
||||||
fn plan_axis_crossing_lanes(
|
fn plan_axis_crossing_lanes(
|
||||||
request: &MultiphaseRequest,
|
request: &MultiphaseRequest,
|
||||||
axis: PhaseAxis,
|
axis: PhaseAxis,
|
||||||
|
|
@ -842,6 +919,154 @@ mod tests {
|
||||||
MultiphaseWindow::new(id(raw), from, to)
|
MultiphaseWindow::new(id(raw), from, to)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum TestTree {
|
||||||
|
Leaf(u32),
|
||||||
|
Split {
|
||||||
|
id: u32,
|
||||||
|
axis: PhaseAxis,
|
||||||
|
weights: Vec<i32>,
|
||||||
|
children: Vec<TestTree>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestLeaf {
|
||||||
|
node_id: NodeId,
|
||||||
|
rect: Rect,
|
||||||
|
hierarchy: MultiphaseHierarchyPosition,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn leaf(raw: u32) -> TestTree {
|
||||||
|
TestTree::Leaf(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split(id: u32, axis: PhaseAxis, weights: &[i32], children: Vec<TestTree>) -> TestTree {
|
||||||
|
TestTree::Split {
|
||||||
|
id,
|
||||||
|
axis,
|
||||||
|
weights: weights.to_vec(),
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout_tree(tree: &TestTree, bounds: Rect) -> Vec<TestLeaf> {
|
||||||
|
let mut leaves = vec![];
|
||||||
|
layout_tree_inner(tree, bounds, None, 0, None, None, &mut leaves);
|
||||||
|
leaves.sort_by_key(|leaf| leaf.node_id.0);
|
||||||
|
leaves
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout_tree_inner(
|
||||||
|
tree: &TestTree,
|
||||||
|
bounds: Rect,
|
||||||
|
parent: Option<NodeId>,
|
||||||
|
depth: u16,
|
||||||
|
sibling_index: Option<u16>,
|
||||||
|
split_axis: Option<PhaseAxis>,
|
||||||
|
leaves: &mut Vec<TestLeaf>,
|
||||||
|
) {
|
||||||
|
match tree {
|
||||||
|
TestTree::Leaf(raw) => leaves.push(TestLeaf {
|
||||||
|
node_id: id(*raw),
|
||||||
|
rect: bounds,
|
||||||
|
hierarchy: MultiphaseHierarchyPosition {
|
||||||
|
parent,
|
||||||
|
depth,
|
||||||
|
sibling_index,
|
||||||
|
split_axis,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
TestTree::Split {
|
||||||
|
id: split_id,
|
||||||
|
axis,
|
||||||
|
weights,
|
||||||
|
children,
|
||||||
|
} => {
|
||||||
|
assert_eq!(weights.len(), children.len());
|
||||||
|
let rects = split_rect_by_weights(bounds, *axis, weights);
|
||||||
|
for (idx, (child, rect)) in children.iter().zip(rects).enumerate() {
|
||||||
|
layout_tree_inner(
|
||||||
|
child,
|
||||||
|
rect,
|
||||||
|
Some(id(*split_id)),
|
||||||
|
depth.saturating_add(1),
|
||||||
|
Some(idx.min(u16::MAX as usize) as u16),
|
||||||
|
Some(*axis),
|
||||||
|
leaves,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_rect_by_weights(bounds: Rect, axis: PhaseAxis, weights: &[i32]) -> Vec<Rect> {
|
||||||
|
let total_weight: i32 = weights.iter().sum();
|
||||||
|
assert!(total_weight > 0);
|
||||||
|
let total_size = match axis {
|
||||||
|
PhaseAxis::Horizontal => bounds.width(),
|
||||||
|
PhaseAxis::Vertical => bounds.height(),
|
||||||
|
};
|
||||||
|
let mut pos = match axis {
|
||||||
|
PhaseAxis::Horizontal => bounds.x1(),
|
||||||
|
PhaseAxis::Vertical => bounds.y1(),
|
||||||
|
};
|
||||||
|
let mut remaining_size = total_size;
|
||||||
|
let mut remaining_weight = total_weight;
|
||||||
|
let mut rects = vec![];
|
||||||
|
for (idx, weight) in weights.iter().enumerate() {
|
||||||
|
let size = if idx + 1 == weights.len() {
|
||||||
|
remaining_size
|
||||||
|
} else {
|
||||||
|
total_size * *weight / total_weight
|
||||||
|
};
|
||||||
|
let rect = match axis {
|
||||||
|
PhaseAxis::Horizontal => {
|
||||||
|
Rect::new_sized_saturating(pos, bounds.y1(), size, bounds.height())
|
||||||
|
}
|
||||||
|
PhaseAxis::Vertical => {
|
||||||
|
Rect::new_sized_saturating(bounds.x1(), pos, bounds.width(), size)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rects.push(rect);
|
||||||
|
pos += size;
|
||||||
|
remaining_size -= size;
|
||||||
|
remaining_weight -= *weight;
|
||||||
|
if remaining_weight == 0 {
|
||||||
|
assert_eq!(remaining_size, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rects
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generated_request(old: &TestTree, new: &TestTree, bounds: Rect) -> MultiphaseRequest {
|
||||||
|
let old_leaves = layout_tree(old, bounds);
|
||||||
|
let new_leaves = layout_tree(new, bounds);
|
||||||
|
assert_eq!(old_leaves.len(), new_leaves.len());
|
||||||
|
let mut windows = vec![];
|
||||||
|
for old_leaf in &old_leaves {
|
||||||
|
let new_leaf = new_leaves
|
||||||
|
.iter()
|
||||||
|
.find(|leaf| leaf.node_id == old_leaf.node_id)
|
||||||
|
.unwrap();
|
||||||
|
windows.push(MultiphaseWindow::with_hierarchy(
|
||||||
|
old_leaf.node_id,
|
||||||
|
old_leaf.rect,
|
||||||
|
new_leaf.rect,
|
||||||
|
MultiphaseWindowHierarchy::new(old_leaf.hierarchy, new_leaf.hierarchy),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
MultiphaseRequest { bounds, windows }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_generated_case_plans(old: &TestTree, new: &TestTree, bounds: Rect) {
|
||||||
|
let req = generated_request(old, new, bounds);
|
||||||
|
assert!(!overlaps(req.windows.iter().map(|window| window.from)));
|
||||||
|
assert!(!overlaps(req.windows.iter().map(|window| window.to)));
|
||||||
|
let plan = plan_no_overlap(&req).unwrap();
|
||||||
|
assert!(validate_plan_continuous(&req, &plan));
|
||||||
|
}
|
||||||
|
|
||||||
fn request(windows: Vec<MultiphaseWindow>) -> MultiphaseRequest {
|
fn request(windows: Vec<MultiphaseWindow>) -> MultiphaseRequest {
|
||||||
let bounds = windows
|
let bounds = windows
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -964,6 +1189,105 @@ mod tests {
|
||||||
assert!(validate_plan_continuous(&req, &plan));
|
assert!(validate_plan_continuous(&req, &plan));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_sibling_swaps_plan_for_both_axes() {
|
||||||
|
let bounds = rect(0, 0, 240, 240);
|
||||||
|
for axis in [PhaseAxis::Horizontal, PhaseAxis::Vertical] {
|
||||||
|
let old = split(10, axis, &[1, 1], vec![leaf(1), leaf(2)]);
|
||||||
|
let new = split(10, axis, &[1, 1], vec![leaf(2), leaf(1)]);
|
||||||
|
assert_generated_case_plans(&old, &new, bounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_size_redistributions_plan_as_single_axis_scale() {
|
||||||
|
let horizontal_old = split(
|
||||||
|
10,
|
||||||
|
PhaseAxis::Horizontal,
|
||||||
|
&[1, 1, 1],
|
||||||
|
vec![leaf(1), leaf(2), leaf(3)],
|
||||||
|
);
|
||||||
|
let horizontal_new = split(
|
||||||
|
10,
|
||||||
|
PhaseAxis::Horizontal,
|
||||||
|
&[1, 2, 1],
|
||||||
|
vec![leaf(1), leaf(2), leaf(3)],
|
||||||
|
);
|
||||||
|
let horizontal_req =
|
||||||
|
generated_request(&horizontal_old, &horizontal_new, rect(0, 0, 400, 100));
|
||||||
|
let horizontal_plan = plan_no_overlap(&horizontal_req).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
actions(&horizontal_plan),
|
||||||
|
vec![PhaseAction {
|
||||||
|
kind: PhaseKind::Scale,
|
||||||
|
axis: PhaseAxis::Horizontal,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert!(validate_plan_continuous(&horizontal_req, &horizontal_plan));
|
||||||
|
|
||||||
|
let vertical_old = split(
|
||||||
|
10,
|
||||||
|
PhaseAxis::Vertical,
|
||||||
|
&[1, 1, 1],
|
||||||
|
vec![leaf(1), leaf(2), leaf(3)],
|
||||||
|
);
|
||||||
|
let vertical_new = split(
|
||||||
|
10,
|
||||||
|
PhaseAxis::Vertical,
|
||||||
|
&[1, 2, 1],
|
||||||
|
vec![leaf(1), leaf(2), leaf(3)],
|
||||||
|
);
|
||||||
|
let vertical_req = generated_request(&vertical_old, &vertical_new, rect(0, 0, 100, 400));
|
||||||
|
let vertical_plan = plan_no_overlap(&vertical_req).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
actions(&vertical_plan),
|
||||||
|
vec![PhaseAction {
|
||||||
|
kind: PhaseKind::Scale,
|
||||||
|
axis: PhaseAxis::Vertical,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert!(validate_plan_continuous(&vertical_req, &vertical_plan));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_stack_extractions_plan_for_both_axes_and_directions() {
|
||||||
|
let horizontal_old = split(
|
||||||
|
10,
|
||||||
|
PhaseAxis::Horizontal,
|
||||||
|
&[1, 1],
|
||||||
|
vec![
|
||||||
|
leaf(1),
|
||||||
|
split(11, PhaseAxis::Vertical, &[1, 1], vec![leaf(2), leaf(3)]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let horizontal_new = split(
|
||||||
|
10,
|
||||||
|
PhaseAxis::Horizontal,
|
||||||
|
&[1, 2, 1],
|
||||||
|
vec![leaf(1), leaf(2), leaf(3)],
|
||||||
|
);
|
||||||
|
assert_generated_case_plans(&horizontal_old, &horizontal_new, rect(0, 0, 400, 100));
|
||||||
|
assert_generated_case_plans(&horizontal_new, &horizontal_old, rect(0, 0, 400, 100));
|
||||||
|
|
||||||
|
let vertical_old = split(
|
||||||
|
20,
|
||||||
|
PhaseAxis::Vertical,
|
||||||
|
&[1, 1],
|
||||||
|
vec![
|
||||||
|
leaf(1),
|
||||||
|
split(21, PhaseAxis::Horizontal, &[1, 1], vec![leaf(2), leaf(3)]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let vertical_new = split(
|
||||||
|
20,
|
||||||
|
PhaseAxis::Vertical,
|
||||||
|
&[1, 2, 1],
|
||||||
|
vec![leaf(1), leaf(2), leaf(3)],
|
||||||
|
);
|
||||||
|
assert_generated_case_plans(&vertical_old, &vertical_new, rect(0, 0, 100, 400));
|
||||||
|
assert_generated_case_plans(&vertical_new, &vertical_old, rect(0, 0, 100, 400));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stack_extraction_creates_space_before_moving_child() {
|
fn stack_extraction_creates_space_before_moving_child() {
|
||||||
let req = request(vec![
|
let req = request(vec![
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue