Grow padding into the random trees, and take children in and out of spans

Padding as a node, with each of the four sides its own number: a padding that
is the same all round hides anything that treats one edge differently from
another. Spans now hold two to four children, so a pattern of removals has
something to make a pattern out of.

Five ways of changing what a span holds, each a shape worth its own case
rather than one shuffle: every other child out, everything but the first out,
three on at once, the first out and three on, and one out of the middle with
one on the end. Each is applied to every third span, and the cold tree is
grown holding exactly what the warm one was left with.

Three spare leaves are grown beside every span whether they end up in it or
not, so a tree that leaves them out makes the same widgets in the same order
as one that puts them in -- otherwise the two trees' `ids` stop lining up at
the first difference and every comparison after it is against the wrong
widget. Attaching one moves it, since a widget belongs to one parent;
`upgrade` is for a weak handle that was never added, not a second share. The
detached children are held until the comparison is over for the same reason:
dropping the last share of one frees its id for the next widget to be given.

Each case asserts the tree actually changed before comparing, so a shuffle
that quietly did nothing fails rather than passes.

All of it agrees: 49 tests, and the ignored sweep over 100 seeds and eight
scenarios, 800 comparisons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-14 13:48:42 -04:00
1 parent 2272634dc5
commit 4178dfbff9
3 files changed
+237 -31

No files matched your search

+2 -2
View File
@@ -2,7 +2,7 @@
//! looked at. `IRIS_SEED` and `IRIS_DEPTH` choose which one. //! looked at. `IRIS_SEED` and `IRIS_DEPTH` choose which one.
use iris::prelude::*; use iris::prelude::*;
use std::collections::HashMap; use iris::random::Edits;
fn env(name: &str, fallback: u64) -> u64 { fn env(name: &str, fallback: u64) -> u64 {
std::env::var(name) std::env::var(name)
@@ -24,7 +24,7 @@ impl DefaultAppState for State {
fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self { fn new(mut ui_state: DefaultUiState, rsc: &mut DefaultRsc<Self>, _: Proxy<Self>) -> Self {
let seed = env("IRIS_SEED", 1); let seed = env("IRIS_SEED", 1);
let depth = env("IRIS_DEPTH", 4) as usize; let depth = env("IRIS_DEPTH", 4) as usize;
let (root, _) = iris::random::grow(rsc, seed, depth, &HashMap::new()); let (root, _) = iris::random::grow(rsc, seed, depth, &Edits::default());
ui_state.set_root(root); ui_state.set_root(root);
Self { ui_state } Self { ui_state }
} }
+89 -19
View File
@@ -11,6 +11,24 @@ use std::collections::HashMap;
/// The declared lengths of one `SetSize`, by axis. /// The declared lengths of one `SetSize`, by axis.
pub type Lens = [Option<Len>; 2]; pub type Lens = [Option<Len>; 2];
/// What a test changes between two trees grown from the same seed, so the
/// warm one can be mutated and the cold one grown that way to begin with.
#[derive(Default)]
pub struct Edits {
/// Declared sizes, by the order the `SetSize` wrappers were made.
pub sizes: HashMap<usize, Lens>,
/// Which children a span has, by the order the spans were made.
pub spans: HashMap<usize, SpanEdit>,
}
#[derive(Default, Clone)]
pub struct SpanEdit {
/// Children to leave out, by index among the ones grown.
pub detach: Vec<usize>,
/// How many of the span's spares are in it, appended in order.
pub attach: usize,
}
/// xorshift64, written out rather than taken from a crate so that a seed /// xorshift64, written out rather than taken from a crate so that a seed
/// keeps meaning the same tree. /// keeps meaning the same tree.
pub struct Rng(u64); pub struct Rng(u64);
@@ -45,6 +63,9 @@ const COLORS: [UiColor; 6] = [
UiColor::MAGENTA, UiColor::MAGENTA,
]; ];
/// Leaves grown beside every span, for a test to put into it.
const SPARES: usize = 3;
const WORDS: &str = "Wrapping shapes one source into as many lines as the box \ const WORDS: &str = "Wrapping shapes one source into as many lines as the box \
leaves room for, so a paragraph's height is an answer and not a setting."; leaves room for, so a paragraph's height is an answer and not a setting.";
@@ -55,6 +76,22 @@ const WORDS: &str = "Wrapping shapes one source into as many lines as the box \
pub struct Tree { pub struct Tree {
pub ids: Vec<WidgetId>, pub ids: Vec<WidgetId>,
pub sized: Vec<WeakWidget<SetSize>>, pub sized: Vec<WeakWidget<SetSize>>,
pub spans: Vec<Spanned>,
/// Children a `SpanEdit` took out, held so that dropping the last share
/// of one does not free its id for the next widget to be given -- which
/// would put the two trees' `ids` out of step.
pub detached: Vec<StrongWidget>,
}
pub struct Spanned {
pub id: WeakWidget<Span>,
/// Leaves grown with the span whether or not they end up in it, so both
/// trees make the same widgets in the same order either way. Attaching
/// one moves it out of here: a widget belongs to one parent, and one that
/// belongs to nobody still has to be held or it reads as a leak.
pub spares: Vec<StrongWidget>,
/// How many children it was grown with, before any edit.
pub grown: usize,
} }
/// Grows the tree `seed` describes, `edits` replacing the declared sizes it /// Grows the tree `seed` describes, `edits` replacing the declared sizes it
@@ -63,7 +100,7 @@ pub fn grow<Rsc: UiRsc + 'static>(
rsc: &mut Rsc, rsc: &mut Rsc,
seed: u64, seed: u64,
depth: usize, depth: usize,
edits: &HashMap<usize, Lens>, edits: &Edits,
) -> (StrongWidget, Tree) { ) -> (StrongWidget, Tree) {
let mut grow = Grow { let mut grow = Grow {
rsc, rsc,
@@ -79,7 +116,7 @@ struct Grow<'a, Rsc> {
rsc: &'a mut Rsc, rsc: &'a mut Rsc,
rng: Rng, rng: Rng,
tree: Tree, tree: Tree,
edits: &'a HashMap<usize, Lens>, edits: &'a Edits,
} }
impl<Rsc: UiRsc + 'static> Grow<'_, Rsc> { impl<Rsc: UiRsc + 'static> Grow<'_, Rsc> {
@@ -117,7 +154,7 @@ impl<Rsc: UiRsc + 'static> Grow<'_, Rsc> {
} }
let idx = self.tree.sized.len(); let idx = self.tree.sized.len();
let lens = [self.len(), self.len()]; let lens = [self.len(), self.len()];
let lens = self.edits.get(&idx).copied().unwrap_or(lens); let lens = self.edits.sizes.get(&idx).copied().unwrap_or(lens);
let id = SetSize { let id = SetSize {
inner, inner,
x: lens[0], x: lens[0],
@@ -133,29 +170,62 @@ impl<Rsc: UiRsc + 'static> Grow<'_, Rsc> {
if depth == 0 { if depth == 0 {
return self.leaf(); return self.leaf();
} }
let count = 2 + self.rng.below(2); if self.rng.below(4) == 0 {
let mut children = Vec::with_capacity(count); let inner = self.node(depth - 1);
for _ in 0..count { let inner = self.sized(inner);
// Each side its own, since a padding that is the same all round
// hides anything that treats one edge differently from another.
let mut side = || self.rng.below(24) as f32;
let padding = Padding {
left: side(),
right: side(),
top: side(),
bottom: side(),
};
let id = Pad { padding, inner }.add_strong(self.rsc);
self.tree.ids.push(id.id());
return id;
}
let grown = 2 + self.rng.below(3);
let mut children = Vec::with_capacity(grown);
for _ in 0..grown {
let child = self.node(depth - 1); let child = self.node(depth - 1);
children.push(self.sized(child)); children.push(self.sized(child));
} }
let id: StrongWidget = match self.rng.below(3) { if self.rng.chance() {
0 => Stack { let id = Stack {
children, children,
size: StackSize::Child(0), size: StackSize::Child(0),
} }
.add_strong(self.rsc), .add_strong(self.rsc);
_ => { self.tree.ids.push(id.id());
let dir = [Dir::RIGHT, Dir::DOWN, Dir::LEFT, Dir::UP][self.rng.below(4)]; return id;
Span { }
children, // Grown either way, so the widget after them has the same id in a
dir, // tree that leaves them out as in one that puts them in.
gap: self.rng.below(3) as f32 * 4.0, let mut spares: Vec<StrongWidget> = (0..SPARES).map(|_| self.leaf()).collect();
} let idx = self.tree.spans.len();
.add_strong(self.rsc) let edit = self.edits.spans.get(&idx).cloned().unwrap_or_default();
// Highest first, so an index means the same child however many of its
// neighbours are going too.
let mut detach = edit.detach.clone();
detach.sort_unstable();
for j in detach.into_iter().rev() {
if j < children.len() {
self.tree.detached.push(children.remove(j));
} }
}; }
let attach = edit.attach.min(spares.len());
children.extend(spares.drain(..attach));
let dir = [Dir::RIGHT, Dir::DOWN, Dir::LEFT, Dir::UP][self.rng.below(4)];
let id = Span {
children,
dir,
gap: self.rng.below(3) as f32 * 4.0,
}
.add(self.rsc);
self.tree.ids.push(id.id()); self.tree.ids.push(id.id());
id self.tree.spans.push(Spanned { id, spares, grown });
id.add_strong(self.rsc)
} }
} }
+146 -10
View File
@@ -14,12 +14,12 @@ use std::collections::HashMap;
use iris::harness::Harness; use iris::harness::Harness;
use iris::prelude::*; use iris::prelude::*;
use iris::random::{Lens, Rng, Tree, grow}; use iris::random::{Edits, Lens, Rng, SpanEdit, Tree, grow};
const DEPTH: usize = 4; const DEPTH: usize = 4;
const SEEDS: [u64; 6] = [1, 2, 3, 5, 8, 13]; const SEEDS: [u64; 6] = [1, 2, 3, 5, 8, 13];
fn plant(h: &mut Harness, seed: u64, edits: &HashMap<usize, Lens>) -> Tree { fn plant(h: &mut Harness, seed: u64, edits: &Edits) -> Tree {
let (root, tree) = grow(&mut h.rsc, seed, DEPTH, edits); let (root, tree) = grow(&mut h.rsc, seed, DEPTH, edits);
h.state.root = Some(root); h.state.root = Some(root);
h.frame(); h.frame();
@@ -44,6 +44,89 @@ fn edit(h: &mut Harness, tree: &Tree, rng: &mut Rng) -> HashMap<usize, Lens> {
edits edits
} }
/// A way of changing what a span holds. Each is a shape worth its own case:
/// taking a child out of the middle is not the same as emptying a span, and
/// adding one is not the same as adding three.
#[derive(Clone, Copy, Debug)]
enum Shuffle {
/// Every other child, so what is left is interleaved with what went.
EveryOther,
/// Everything but the first, which is the last step before empty.
AllButFirst,
/// Three more on the end at once.
AddThree,
/// The first out and three more on, so the count moves both ways.
SwapForThree,
/// One out of the middle and one on the end.
TradeOne,
}
const SHUFFLES: [Shuffle; 5] = [
Shuffle::EveryOther,
Shuffle::AllButFirst,
Shuffle::AddThree,
Shuffle::SwapForThree,
Shuffle::TradeOne,
];
impl Shuffle {
fn of(self, grown: usize) -> SpanEdit {
let all = |step: usize, from: usize| (from..grown).step_by(step).collect();
match self {
Self::EveryOther => SpanEdit {
detach: all(2, 0),
attach: 0,
},
Self::AllButFirst => SpanEdit {
detach: all(1, 1),
attach: 0,
},
Self::AddThree => SpanEdit {
detach: Vec::new(),
attach: 3,
},
Self::SwapForThree => SpanEdit {
detach: vec![0],
attach: 3,
},
Self::TradeOne => SpanEdit {
detach: vec![grown / 2],
attach: 1,
},
}
}
}
/// Applies `shuffle` to every third span, and says what it did so the cold
/// tree can be grown that way. The widgets it takes out are given back: the
/// last share of one must outlive the comparison, or its id is handed to
/// something else and the two trees stop lining up.
fn reshuffle(
h: &mut Harness,
tree: &mut Tree,
shuffle: Shuffle,
) -> (HashMap<usize, SpanEdit>, Vec<StrongWidget>) {
let mut edits = HashMap::new();
let mut detached = Vec::new();
for (idx, span) in tree.spans.iter_mut().enumerate().step_by(3) {
let span_edit = shuffle.of(span.grown);
let mut take = span_edit.detach.clone();
take.sort_unstable();
let children = &mut h.rsc[span.id].children;
// Highest first, so an index means the same child however many of
// its neighbours are going too.
for j in take.into_iter().rev() {
if j < children.len() {
detached.push(children.remove(j));
}
}
let attach = span_edit.attach.min(span.spares.len());
children.extend(span.spares.drain(..attach));
edits.insert(idx, span_edit);
}
(edits, detached)
}
/// Every widget in one tree against the matching widget in the other. A /// Every widget in one tree against the matching widget in the other. A
/// mismatch prints the widget's ancestry, marking the ones that own a slot, /// mismatch prints the widget's ancestry, marking the ones that own a slot,
/// since where two trees disagree is rarely where the cause is. /// since where two trees disagree is rarely where the cause is.
@@ -83,42 +166,83 @@ fn assert_same(seed: u64, what: &str, warm: (&Harness, &Tree), cold: (&Harness,
fn changed_size(seed: u64) { fn changed_size(seed: u64) {
let mut warm = Harness::new((900, 1200)); let mut warm = Harness::new((900, 1200));
let grown = plant(&mut warm, seed, &HashMap::new()); let grown = plant(&mut warm, seed, &Edits::default());
let mut rng = Rng::new(seed ^ 0x5eed); let mut rng = Rng::new(seed ^ 0x5eed);
let edits = edit(&mut warm, &grown, &mut rng); let sizes = edit(&mut warm, &grown, &mut rng);
warm.frame(); warm.frame();
let mut cold = Harness::new((900, 1200)); let mut cold = Harness::new((900, 1200));
let same = plant(&mut cold, seed, &edits); let same = plant(
&mut cold,
seed,
&Edits {
sizes,
..Default::default()
},
);
assert_same(seed, "a size change", (&warm, &grown), (&cold, &same)); assert_same(seed, "a size change", (&warm, &grown), (&cold, &same));
} }
fn reshuffled(seed: u64, shuffle: Shuffle) {
let mut warm = Harness::new((900, 1200));
let mut grown = plant(&mut warm, seed, &Edits::default());
let before: Vec<_> = grown.ids.iter().map(|id| warm.region(id)).collect();
let (spans, _held) = reshuffle(&mut warm, &mut grown, shuffle);
warm.frame();
// Or the two trees would agree for want of anything having happened.
let after = grown.ids.iter().map(|id| warm.region(id));
let moved = before.iter().zip(after).filter(|(a, b)| *a != b).count();
assert!(moved > 0, "seed {seed}: {shuffle:?} changed nothing");
let mut cold = Harness::new((900, 1200));
let same = plant(
&mut cold,
seed,
&Edits {
spans,
..Default::default()
},
);
let what = format!("{shuffle:?}");
assert_same(seed, &what, (&warm, &grown), (&cold, &same));
}
fn resized(seed: u64) { fn resized(seed: u64) {
let mut warm = Harness::new((1920, 1200)); let mut warm = Harness::new((1920, 1200));
let grown = plant(&mut warm, seed, &HashMap::new()); let grown = plant(&mut warm, seed, &Edits::default());
warm.resize((640, 900)); warm.resize((640, 900));
warm.frame(); warm.frame();
let mut cold = Harness::new((640, 900)); let mut cold = Harness::new((640, 900));
let same = plant(&mut cold, seed, &HashMap::new()); let same = plant(&mut cold, seed, &Edits::default());
assert_same(seed, "a resize", (&warm, &grown), (&cold, &same)); assert_same(seed, "a resize", (&warm, &grown), (&cold, &same));
} }
fn resized_then_changed(seed: u64) { fn resized_then_changed(seed: u64) {
let mut warm = Harness::new((1920, 1200)); let mut warm = Harness::new((1920, 1200));
let grown = plant(&mut warm, seed, &HashMap::new()); let grown = plant(&mut warm, seed, &Edits::default());
warm.resize((640, 900)); warm.resize((640, 900));
warm.frame(); warm.frame();
let mut rng = Rng::new(seed ^ 0xb0a7); let mut rng = Rng::new(seed ^ 0xb0a7);
let edits = edit(&mut warm, &grown, &mut rng); let sizes = edit(&mut warm, &grown, &mut rng);
warm.frame(); warm.frame();
let mut cold = Harness::new((640, 900)); let mut cold = Harness::new((640, 900));
let same = plant(&mut cold, seed, &edits); let same = plant(
&mut cold,
seed,
&Edits {
sizes,
..Default::default()
},
);
let what = "a resize then a size change"; let what = "a resize then a size change";
assert_same(seed, what, (&warm, &grown), (&cold, &same)); assert_same(seed, what, (&warm, &grown), (&cold, &same));
@@ -139,6 +263,15 @@ fn a_size_change_after_a_resize_lands_the_same_way() {
SEEDS.into_iter().for_each(resized_then_changed); SEEDS.into_iter().for_each(resized_then_changed);
} }
#[test]
fn adding_and_removing_span_children_lands_where_growing_it_that_way_would() {
for shuffle in SHUFFLES {
for seed in SEEDS {
reshuffled(seed, shuffle);
}
}
}
/// Reproduces a divergence that predates the position chain: laying a tree out /// Reproduces a divergence that predates the position chain: laying a tree out
/// again does not always land where growing it cold does. /// again does not always land where growing it cold does.
/// ///
@@ -165,5 +298,8 @@ fn a_long_run_of_seeds_agrees() {
changed_size(seed); changed_size(seed);
resized(seed); resized(seed);
resized_then_changed(seed); resized_then_changed(seed);
for shuffle in SHUFFLES {
reshuffled(seed, shuffle);
}
} }
} }