Delete OrthoSize, and run the seeds in parallel

A span is as long across itself as its longest child, unless a rule beside it
already says how long it is -- and then reading the children answers nothing
and only makes its size depend on theirs. `OrthoSize::Full` was that second
case written twice, once as an enum on the span and once as the rule that
actually decides; `Painter::ruled` lets the span ask which it is in. The
widget under a rule still does not learn what the rule says, only that its
answer for that axis is not wanted.

The fuzzers grow, lay out and drop a tree within one seed, so the seeds share
nothing and take a thread each, one short of every core. Measured here: the
generated oracle's hundred seeds went from 68 s to 10 s, and a shrinker case
at 300 seeds from 18 s to 3.5 s. A seed that fails still shrinks and panics
on its own thread, and `std::thread::scope` carries that out.

The shrinker now allows the two steps the oracle already did -- the deeper
trees these grow reach a second composition, and a step is a thousandth of a
pixel.

Checked: fmt, clippy, 102 tests, all five shrinker cases at 300 seeds, 100
generated seeds, and five examples byte-identical at 1920x1200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-16 03:18:49 -04:00
1 parent cb955f1023
commit 9d8415d65f
8 files changed
+93 -73

No files matched your search

+14
View File
@@ -353,6 +353,20 @@ impl<'a> Painter<'a> {
self.rsc.widgets().alignment(self.id) self.rsc.widgets().alignment(self.id)
} }
/// Whether a rule beside this widget settles its length on `axis`, which
/// makes whatever it reports for that axis moot. The widget under a rule
/// does not otherwise learn of it -- this is for a container deciding
/// whether reading its children across an axis is worth anything, since
/// reading one is also what makes its own size depend on it.
pub fn ruled(&self, axis: Axis) -> bool {
self.rsc
.widgets()
.size_rules(self.id)
.axis(axis)
.known()
.is_some()
}
/// The part of this widget's box that something of `size` takes, at the /// The part of this widget's box that something of `size` takes, at the
/// near edge. A container that reports one child's size gives every child /// near edge. A container that reports one child's size gives every child
/// this, so what it draws is inside what it says it occupies. /// this, so what it draws is inside what it says it occupies.
+9 -6
View File
@@ -358,14 +358,17 @@ impl<Rsc: UiRsc + 'static> Grow<'_, Rsc> {
children, children,
dir, dir,
gap: Px::from_int(self.rng.below(3) as i32 * 4), gap: Px::from_int(self.rng.below(3) as i32 * 4),
// Derive this from an existing choice: a seed must keep growing
// the same tree when the generator gains another configuration.
ortho: match dir.axis {
Axis::X => OrthoSize::Full,
Axis::Y => OrthoSize::Children,
},
} }
.add(self.rsc); .add(self.rsc);
// A row takes the height it is given rather than its tallest child,
// which is a rule beside it. Derived from an existing choice and
// consuming no randomness: a seed must keep growing the same tree
// when the generator gains another configuration.
if dir.axis == Axis::X {
self.rsc
.widgets_mut()
.set_size_rules(id, None, Some(Len::rel(1.0)));
}
self.tree.ids.push(id.id()); self.tree.ids.push(id.id());
self.tree.spans.push(Spanned { id, spares, grown }); self.tree.spans.push(Spanned { id, spares, grown });
id.add_strong(self.rsc) id.add_strong(self.rsc)
+8 -29
View File
@@ -1,21 +1,10 @@
use crate::prelude::*; use crate::prelude::*;
use std::marker::PhantomData; use std::marker::PhantomData;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum OrthoSize {
/// Reports one full relative length across the span's orthogonal axis,
/// whether or not its siblings also take space.
Full,
/// Reports its longest fixed child, or leftover space if any child scales.
#[default]
Children,
}
pub struct Span { pub struct Span {
pub children: Vec<StrongWidget>, pub children: Vec<StrongWidget>,
pub dir: Dir, pub dir: Dir,
pub gap: Px, pub gap: Px,
pub ortho: OrthoSize,
} }
impl Widget for Span { impl Widget for Span {
@@ -90,6 +79,10 @@ impl Widget for Span {
painter.holds(axis, holds); painter.holds(axis, holds);
} }
// Across itself a span is as long as its longest child -- unless a
// rule beside it already says how long it is, and then reading them
// answers nothing and makes its size depend on theirs for it.
let shrinks = !painter.ruled(!axis);
let mut start = UiScalar::rel_min(); let mut start = UiScalar::rel_min();
let mut ortho = Len::ZERO; let mut ortho = Len::ZERO;
for (child, len) in self.children.iter().zip(&lens) { for (child, len) in self.children.iter().zip(&lens) {
@@ -119,7 +112,7 @@ impl Widget for Span {
region.flip(axis); region.flip(axis);
} }
let placed = painter.widget_within(child, region); let placed = painter.widget_within(child, region);
if self.ortho == OrthoSize::Children { if shrinks {
let used = placed.len(!axis); let used = placed.len(!axis);
// Choosing between a fixed and a relative length from the // Choosing between a fixed and a relative length from the
// span's own eventual width admits multiple fixed points. // span's own eventual width admits multiple fixed points.
@@ -142,9 +135,9 @@ impl Widget for Span {
// not give. Resolution happens at the nearest ancestor with a length, // not give. Resolution happens at the nearest ancestor with a length,
// and the root always has one. // and the root always has one.
let along = total; let along = total;
let ortho = match self.ortho { let ortho = match shrinks {
OrthoSize::Full => Len::rel(1.0), true => ortho,
OrthoSize::Children => ortho, false => Len::rel(1.0),
}; };
Size::from_axis(axis, along, ortho) Size::from_axis(axis, along, ortho)
} }
@@ -156,7 +149,6 @@ impl Span {
children: Vec::new(), children: Vec::new(),
dir, dir,
gap: Px::ZERO, gap: Px::ZERO,
ortho: OrthoSize::Children,
} }
} }
@@ -165,11 +157,6 @@ impl Span {
self self
} }
pub fn ortho(mut self, ortho: OrthoSize) -> Self {
self.ortho = ortho;
self
}
pub fn push(&mut self, w: StrongWidget) { pub fn push(&mut self, w: StrongWidget) {
self.children.push(w); self.children.push(w);
} }
@@ -183,7 +170,6 @@ pub struct SpanBuilder<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Ta
pub children: Wa, pub children: Wa,
pub dir: Dir, pub dir: Dir,
pub gap: Px, pub gap: Px,
pub ortho: OrthoSize,
_pd: PhantomData<(State, Tag)>, _pd: PhantomData<(State, Tag)>,
} }
@@ -198,7 +184,6 @@ impl<Rsc, const LEN: usize, Wa: WidgetArrLike<Rsc, LEN, Tag>, Tag> WidgetFnTrait
children: self.children.add(rsc).arr.into_iter().collect(), children: self.children.add(rsc).arr.into_iter().collect(),
dir: self.dir, dir: self.dir,
gap: self.gap, gap: self.gap,
ortho: self.ortho,
} }
} }
} }
@@ -211,7 +196,6 @@ impl<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag>
children, children,
dir, dir,
gap: Px::ZERO, gap: Px::ZERO,
ortho: OrthoSize::Children,
_pd: PhantomData, _pd: PhantomData,
} }
} }
@@ -220,11 +204,6 @@ impl<State, const LEN: usize, Wa: WidgetArrLike<State, LEN, Tag>, Tag>
self.gap = Px::from_num(gap); self.gap = Px::from_num(gap);
self self
} }
pub fn ortho(mut self, ortho: OrthoSize) -> Self {
self.ortho = ortho;
self
}
} }
impl std::ops::Deref for Span { impl std::ops::Deref for Span {
+5 -14
View File
@@ -21,27 +21,21 @@ fn a_span_gives_each_child_the_width_it_asked_for() {
} }
#[test] #[test]
fn a_full_ortho_span_reports_relative_full() { fn a_span_ruled_across_itself_does_not_measure_its_children_there() {
let mut h = Harness::new((400, 200)); let mut h = Harness::new((400, 200));
let child = rect(Color::RED).height(40).add(&mut h.rsc); let child = rect(Color::RED).height(40).add(&mut h.rsc);
let span = (child,) let span = (child,).span(Dir::RIGHT).height(rel(1.0)).add(&mut h.rsc);
.span(Dir::RIGHT)
.ortho(OrthoSize::Full)
.add(&mut h.rsc);
h.set_root(span); h.set_root(span);
assert_eq!(h.render.active[&span.id()].size.y, Len::rel(1.0)); assert_eq!(h.render.active[&span.id()].size.y, Len::rel(1.0));
} }
#[test] #[test]
fn a_children_ortho_span_reports_its_tallest_fixed_child() { fn a_span_reports_its_tallest_fixed_child() {
let mut h = Harness::new((400, 200)); let mut h = Harness::new((400, 200));
let short = rect(Color::RED).height(40).add(&mut h.rsc); let short = rect(Color::RED).height(40).add(&mut h.rsc);
let tall = rect(Color::BLUE).height(70).add(&mut h.rsc); let tall = rect(Color::BLUE).height(70).add(&mut h.rsc);
let span = (short, tall) let span = (short, tall).span(Dir::RIGHT).add(&mut h.rsc);
.span(Dir::RIGHT)
.ortho(OrthoSize::Children)
.add(&mut h.rsc);
h.set_root(span); h.set_root(span);
assert_eq!(h.render.active[&span.id()].size.y, Len::px(70.0)); assert_eq!(h.render.active[&span.id()].size.y, Len::px(70.0));
@@ -211,10 +205,7 @@ fn a_box_with_a_fixed_length_can_be_stretched_on_its_other_axis() {
let filler = rect(Color::GREEN).add(&mut h.rsc); let filler = rect(Color::GREEN).add(&mut h.rsc);
// This column is an item in a row, so it takes the width left for it // This column is an item in a row, so it takes the width left for it
// rather than asking for a full row-width in addition to the bar. // rather than asking for a full row-width in addition to the bar.
let column = (row, filler) let column = (row, filler).span(Dir::DOWN).add(&mut h.rsc);
.span(Dir::DOWN)
.ortho(OrthoSize::Children)
.add(&mut h.rsc);
let bar = rect(Color::RED).width(100).add(&mut h.rsc); let bar = rect(Color::RED).width(100).add(&mut h.rsc);
h.set_root((bar, column).span(Dir::RIGHT)); h.set_root((bar, column).span(Dir::RIGHT));
assert_corners!(h, inner, (110, 10), (390, 30)); assert_corners!(h, inner, (110, 10), (390, 30));
+2 -5
View File
@@ -300,13 +300,10 @@ fn a_resize_does_not_redraw_what_the_shader_can_move() {
} }
#[test] #[test]
fn a_full_ortho_span_moves_its_child_without_redrawing_it() { fn a_span_ruled_across_itself_moves_its_child_without_redrawing_it() {
let mut h = Harness::new((400, 200)); let mut h = Harness::new((400, 200));
let (leaf, draws) = counted(&mut h, Size::LEFTOVER, false); let (leaf, draws) = counted(&mut h, Size::LEFTOVER, false);
let span = (leaf,) let span = (leaf,).span(Dir::RIGHT).height(rel(1.0)).add(&mut h.rsc);
.span(Dir::RIGHT)
.ortho(OrthoSize::Full)
.add(&mut h.rsc);
h.set_root(span); h.set_root(span);
let settled = draws.get(); let settled = draws.get();
-5
View File
@@ -160,7 +160,6 @@ fn plant_pair(h: &mut Harness, swapped: bool) -> (Vec<WidgetId>, WeakWidget<Span
children, children,
dir: Dir::RIGHT, dir: Dir::RIGHT,
gap: Px::ZERO, gap: Px::ZERO,
ortho: OrthoSize::Children,
} }
.add(&mut h.rsc); .add(&mut h.rsc);
let span_handle = span; let span_handle = span;
@@ -214,7 +213,6 @@ fn plant_scrolled(h: &mut Harness, swapped: bool) -> (Vec<WidgetId>, [WeakWidget
children: inner_children, children: inner_children,
dir: Dir::RIGHT, dir: Dir::RIGHT,
gap: Px::ZERO, gap: Px::ZERO,
ortho: OrthoSize::Children,
} }
.add(&mut h.rsc); .add(&mut h.rsc);
let block = rect(Color::RED).add(&mut h.rsc); let block = rect(Color::RED).add(&mut h.rsc);
@@ -228,7 +226,6 @@ fn plant_scrolled(h: &mut Harness, swapped: bool) -> (Vec<WidgetId>, [WeakWidget
children: outer_children, children: outer_children,
dir: Dir::RIGHT, dir: Dir::RIGHT,
gap: Px::ZERO, gap: Px::ZERO,
ortho: OrthoSize::Children,
} }
.add(&mut h.rsc); .add(&mut h.rsc);
// Carried no rule even before rules were a property: it is here to be a // Carried no rule even before rules were a property: it is here to be a
@@ -337,7 +334,6 @@ fn plant_boundary(h: &mut Harness, swapped: bool) -> (Vec<WidgetId>, [WeakWidget
children: pair, children: pair,
dir: Dir::DOWN, dir: Dir::DOWN,
gap: Px::ZERO, gap: Px::ZERO,
ortho: OrthoSize::Children,
} }
.add(&mut h.rsc); .add(&mut h.rsc);
// Takes the whole box on its own, so the span above has nothing left to // Takes the whole box on its own, so the span above has nothing left to
@@ -357,7 +353,6 @@ fn plant_boundary(h: &mut Harness, swapped: bool) -> (Vec<WidgetId>, [WeakWidget
children: inner_children, children: inner_children,
dir: Dir::DOWN, dir: Dir::DOWN,
gap: Px::ZERO, gap: Px::ZERO,
ortho: OrthoSize::Children,
} }
.add(&mut h.rsc); .add(&mut h.rsc);
h.rsc h.rsc
+20 -3
View File
@@ -216,10 +216,9 @@ fn describe_widget(id: WidgetId, h: &Harness) -> String {
if let Some(w) = any.downcast_ref::<Span>() { if let Some(w) = any.downcast_ref::<Span>() {
let sign = if w.dir.sign == Sign::Neg { "-" } else { "+" }; let sign = if w.dir.sign == Sign::Neg { "-" } else { "+" };
return format!( return format!(
"Span{{dir:{:?}{sign},gap:{},ortho:{:?},n:{}}}", "Span{{dir:{:?}{sign},gap:{},n:{}}}",
w.dir.axis, w.dir.axis,
w.gap, w.gap,
w.ortho,
w.children.len() w.children.len()
); );
} }
@@ -555,7 +554,7 @@ fn a_long_run_of_seeds_agrees() {
.and_then(|seed| seed.parse().ok()) .and_then(|seed| seed.parse().ok())
.map(|seed| seed..=seed) .map(|seed| seed..=seed)
.unwrap_or_else(|| 1..=env("IRIS_GENERATED_SEEDS", 100)); .unwrap_or_else(|| 1..=env("IRIS_GENERATED_SEEDS", 100));
for seed in seeds { over_seeds(seeds.collect(), |seed| {
changed_size(seed); changed_size(seed);
changed_every_size(seed); changed_every_size(seed);
repainted_together(seed); repainted_together(seed);
@@ -564,5 +563,23 @@ fn a_long_run_of_seeds_agrees() {
for shuffle in SHUFFLES { for shuffle in SHUFFLES {
reshuffled(seed, shuffle); reshuffled(seed, shuffle);
} }
});
}
/// Every seed on its own thread's share of them. A tree is grown, laid out
/// and dropped inside one call, so seeds share nothing, and this is most of
/// the time a run takes. A thread that fails takes the scope down with it,
/// which is the same panic libtest would have seen.
///
/// One core short of all of them, so the machine this runs on stays usable.
pub fn over_seeds(seeds: Vec<u64>, run: impl Fn(u64) + Sync) {
let threads =
std::thread::available_parallelism().map_or(1, |n| n.get().saturating_sub(1).max(1));
let chunk = seeds.len().div_ceil(threads).max(1);
std::thread::scope(|scope| {
for part in seeds.chunks(chunk) {
let run = &run;
scope.spawn(move || part.iter().for_each(|&seed| run(seed)));
} }
});
} }
+35 -11
View File
@@ -51,6 +51,10 @@ const WORDS: &[&str] = &[
const ONE_LINE: &str = "one line, overflowing whatever it is given"; const ONE_LINE: &str = "one line, overflowing whatever it is given";
const OUTER: (f32, f32) = (1920.0, 1200.0); const OUTER: (f32, f32) = (1920.0, 1200.0);
/// Steps of the grid two ways of reaching a box may differ by. See
/// `docs/HANDOFF.md`'s "Fixed point" in `ai-app-2` for where the last of
/// them is.
const AGREE_STEPS: i32 = 2;
const INNER: (f32, f32) = (640.0, 900.0); const INNER: (f32, f32) = (640.0, 900.0);
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -120,12 +124,15 @@ impl Node {
children, children,
dir: dir(*down), dir: dir(*down),
gap: Px::from_f32(*gap), gap: Px::from_f32(*gap),
ortho: match down {
true => OrthoSize::Children,
false => OrthoSize::Full,
},
} }
.add(&mut h.rsc); .add(&mut h.rsc);
// A row takes the height it is given; a column is as wide
// as its widest child, which needs no rule.
if !*down {
h.rsc
.widgets_mut()
.set_size_rules(handle, None, Some(Len::rel(1.0)));
}
spans.push(handle); spans.push(handle);
handle.add_strong(&mut h.rsc) handle.add_strong(&mut h.rsc)
} }
@@ -515,12 +522,13 @@ fn diverges(node: &Node, case: Case) -> Option<String> {
for (i, (&w, &c)) in warm_ids.iter().zip(&cold_ids).enumerate() { for (i, (&w, &c)) in warm_ids.iter().zip(&cold_ids).enumerate() {
let (got, want) = (warm.region(&w), cold.region(&c)); let (got, want) = (warm.region(&w), cold.region(&c));
// To one step of the grid. A move or a resize lands on the same // To a couple of steps of the grid, each a thousandth of a pixel: a
// number now; a length measured one way and composed another can // move or a resize lands on the same number now, and a length
// still be a step apart. // measured one way against the same length composed another can
// still be a step out per composition between them.
let same = match (got, want) { let same = match (got, want) {
(Some(g), Some(c)) => { (Some(g), Some(c)) => {
let d = |a: Px, b: Px| (a - b).abs() <= Px::STEP; let d = |a: Px, b: Px| (a - b).abs() <= Px::STEP.mul_int(AGREE_STEPS);
d(g.top_left.x, c.top_left.x) d(g.top_left.x, c.top_left.x)
&& d(g.top_left.y, c.top_left.y) && d(g.top_left.y, c.top_left.y)
&& d(g.bot_right.x, c.bot_right.x) && d(g.bot_right.x, c.bot_right.x)
@@ -550,6 +558,22 @@ fn shrink(mut node: Node, case: Case) -> Node {
} }
} }
/// One thread per core but one, each taking a share of the seeds: a tree is
/// grown, laid out and dropped within a seed, so nothing is shared. A seed
/// that fails shrinks on its own thread and panics there, which brings the
/// scope down with it.
fn over_seeds(seeds: Vec<u64>, run: impl Fn(u64) + Sync) {
let threads =
std::thread::available_parallelism().map_or(1, |n| n.get().saturating_sub(1).max(1));
let chunk = seeds.len().div_ceil(threads).max(1);
std::thread::scope(|scope| {
for part in seeds.chunks(chunk) {
let run = &run;
scope.spawn(move || part.iter().for_each(|&seed| run(seed)));
}
});
}
fn env<T: std::str::FromStr>(name: &str, fallback: T) -> T { fn env<T: std::str::FromStr>(name: &str, fallback: T) -> T {
std::env::var(name) std::env::var(name)
.ok() .ok()
@@ -570,10 +594,10 @@ fn no_grown_tree_lays_out_differently_warm_than_cold() {
_ => Case::Resize, _ => Case::Resize,
}; };
for seed in 1..=seeds { over_seeds((1..=seeds).collect(), |seed| {
let node = grow(&mut Rng::new(seed), depth); let node = grow(&mut Rng::new(seed), depth);
let Some(how) = diverges(&node, case) else { let Some(how) = diverges(&node, case) else {
continue; return;
}; };
let small = shrink(node.clone(), case); let small = shrink(node.clone(), case);
println!( println!(
@@ -582,7 +606,7 @@ fn no_grown_tree_lays_out_differently_warm_than_cold() {
small.size() small.size()
); );
panic!("seed {seed} lays out differently warm than cold"); panic!("seed {seed} lays out differently warm than cold");
} });
let sizes: Vec<usize> = (1..=seeds) let sizes: Vec<usize> = (1..=seeds)
.map(|seed| grow(&mut Rng::new(seed), depth).size()) .map(|seed| grow(&mut Rng::new(seed), depth).size())
.collect(); .collect();