From cbccfb600a079444357eb033885c8d4a5b7dfb4e Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Sun, 20 Sep 2026 22:04:07 -0400 Subject: [PATCH] Say a length that is zero, and share the seed a scan takes A full sweep of #19, and the first review of f48e04e. `Display for LayoutLen` leaves out every part that is zero, so `LayoutLen::ZERO` printed as the empty string -- and `Debug` forwards to `Display` since the last commit, so the four `assert_eq!`s in `cases/deferred.rs` print nothing where a request of zero is, and `scenario::describe` prints a `.width(0)` rule as `-`, which is what it prints for a widget that has no rule at all. That file exists so a tree a fuzzer found can be written out by hand; a value it cannot say is a hole in the one thing it is for. `Fixed::ceil_from_f32` took `next_up` of a `from_f32` that had already clamped, and `next_up` wraps, so a measurement past the top of the grid came back as the bottom of it. `from_f32` clamps deliberately because a float has further to come from; the ceiling is the other way in from a float and now holds to the same rule. The check goes beside the one `from_f32` already had. `Moves::depth` walked the move chain a second way, with its own copy of `CHAIN_LIMIT` and without the assertion `walk` makes; it is `walk` now, so the CPU counts the chain in one place and the shader's constant reaches both. `Harness::set_len` said it set a length "the way `.width()` sets one" and wrote the whole rule instead, dropping any bound beside it. A case that set a bound and then a length would have passed with no bound at all. Three rigs each spelled "one seed, or a range of them" by hand -- the class the eleventh sweep closed for reading a parameter and not for this. There is one `rig::seeds` now. `cases/deferred` was last in `suite.rs`'s otherwise alphabetical list. `diag::outside` writes out `AxisHolds::contains`'s four clauses to say which one refused a reuse; a debug assertion now catches a fifth clause added there and not here, which would leave a refusal counted and unexplained. `Sow::bound`'s comment recorded an open hole reached by seeds 4 and 196 at depth 5 -- but `generated.rs` says seeds stopped naming those trees when the leaves grew images, and 600 depth-5 trees over all sixteen cases agree warm against cold with every bound a fraction. The comment says what is true now and why the generator still grows pixels. Format, workspace clippy under -D warnings with and without layout-diagnostics, 208 ordinary and 212 diagnostic tests (207 and 211 before, plus the one this adds), and the cold dump byte-identical to f48e04e across all 34,986 boxes. The three seed scans were not run: nothing here can move a box, which the dump confirms. Co-Authored-By: Claude Opus 5 --- core/src/fixed.rs | 9 ++++++++- core/src/layout_diagnostics.rs | 17 +++++++++++++---- core/src/orientation/len.rs | 27 +++++++++++++++++++++++++++ core/src/ui/mod.rs | 6 +----- src/harness.rs | 8 ++++---- src/random.rs | 17 +++++++++-------- tests/deferred_generated.rs | 6 +----- tests/generated.rs | 8 +------- tests/rig/mod.rs | 13 +++++++++++++ tests/shrink.rs | 8 +------- tests/suite.rs | 5 ++--- 11 files changed, 80 insertions(+), 44 deletions(-) diff --git a/core/src/fixed.rs b/core/src/fixed.rs index 8da9d48..d5aad3b 100644 --- a/core/src/fixed.rs +++ b/core/src/fixed.rs @@ -115,7 +115,10 @@ impl Fixed { /// that leaves out the thing it was measured from. pub const fn ceil_from_f32(v: f32) -> Self { let nearest = Self::from_f32(v); - match nearest.to_f32() < v { + // The top of the grid has no step above it, and stepping past it + // wraps to the bottom. A value out there is a caller's mistake, and + // the clamp `from_f32` already made is the answer to it. + match nearest.to_f32() < v && nearest.0 != Self::MAX.0 { true => nearest.next_up(), false => nearest, } @@ -511,6 +514,10 @@ mod tests { fn a_number_from_outside_is_clamped_to_the_grid() { assert_eq!(Px::from_f32(1e12), Px::MAX); assert_eq!(Px::from_f32(-1e12), Px::MIN); + // The ceiling is the other way in from a float, and there is no step + // above the top of the grid for it to take. + assert_eq!(Px::ceil_from_f32(1e12), Px::MAX); + assert_eq!(Px::ceil_from_f32(-1e12), Px::MIN); } #[test] diff --git a/core/src/layout_diagnostics.rs b/core/src/layout_diagnostics.rs index ffe4e29..427d2d5 100644 --- a/core/src/layout_diagnostics.rs +++ b/core/src/layout_diagnostics.rs @@ -363,26 +363,35 @@ pub(crate) fn outside( rel_base: UiVec2, window: PxVec2, ) { + let mut reasons = 0; + let mut why = |counter| { + bump(counter); + reasons += 1; + }; for axis in Axis::BOTH { let holds = holds[axis]; let len = region[axis].len(); let window = window[axis]; if holds.region_len.is_some_and(|pinned| pinned != len) { - bump(Counter::OutsidePinnedLen); + why(Counter::OutsidePinnedLen); } if !holds.window.contains(window) { - bump(Counter::OutsideWindow); + why(Counter::OutsideWindow); } if holds .rel_base .is_some_and(|pinned| pinned != rel_base[axis]) { - bump(Counter::OutsideRelBase); + why(Counter::OutsideRelBase); } if !holds.region.contains(len.to_px(window)) { - bump(Counter::OutsideRegion); + why(Counter::OutsideRegion); } } + // These four are `AxisHolds::contains`'s four clauses written out again, + // because the report wants which one refused rather than that one did. A + // clause added there and not here would leave a refusal unexplained. + debug_assert!(reasons > 0, "a reuse was refused for no reason counted"); bump(Counter::ReuseOutside); reuse(id, ReuseOutcome::Outside); } diff --git a/core/src/orientation/len.rs b/core/src/orientation/len.rs index 81205e0..530e4ec 100644 --- a/core/src/orientation/len.rs +++ b/core/src/orientation/len.rs @@ -247,6 +247,12 @@ impl std::fmt::Display for Size { impl std::fmt::Display for LayoutLen { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // A part that is zero is left out, so a length that is zero all + // through would print as nothing -- which reads as no length at all + // wherever one is printed beside something that has none. + if *self == Self::ZERO { + return write!(f, "0 px;"); + } if self.px != Px::ZERO { write!(f, "{} px;", self.px)?; } @@ -261,3 +267,24 @@ impl std::fmt::Display for LayoutLen { } impl_axis_index!(Size => LayoutLen); + +#[cfg(test)] +mod tests { + use super::*; + + /// What a request prints as is how a failing case is read and written out + /// again, and a length that printed as nothing could not be told from a + /// widget that has no rule at all. + #[test] + fn every_length_prints_as_something() { + for len in [ + LayoutLen::ZERO, + LayoutLen::px(8), + LayoutLen::rel(0.5), + LayoutLen::LEFTOVER, + ] { + assert!(!len.to_string().is_empty(), "{len:?} printed as nothing"); + } + assert_eq!(LayoutLen::ZERO.to_string(), "0 px;"); + } +} diff --git a/core/src/ui/mod.rs b/core/src/ui/mod.rs index e45003c..7ff905d 100644 --- a/core/src/ui/mod.rs +++ b/core/src/ui/mod.rs @@ -103,11 +103,7 @@ impl Moves { /// the shader's walk costs per primitive. pub fn depth(&self, idx: MoveIdx) -> usize { let mut depth = 0; - let mut at = idx; - while at != MoveIdx::NONE && depth < CHAIN_LIMIT as usize { - at = self.arena[at.idx()].parent; - depth += 1; - } + self.walk(idx, |_| depth += 1); depth } diff --git a/src/harness.rs b/src/harness.rs index 8354a34..c2c5f2c 100644 --- a/src/harness.rs +++ b/src/harness.rs @@ -164,11 +164,11 @@ impl Harness { self.render.resize(size, self.rsc.widgets_mut()); } - /// Changes a length rule after the fact, the way `.width()` sets one. + /// Changes a length rule after the fact, the way `.width()` sets one -- + /// which leaves a bound beside it alone, where writing the whole rule + /// would drop it and pass the case for the wrong reason. pub fn set_len(&mut self, id: impl IdLike, axis: Axis, len: impl Into) { - self.rsc - .widgets_mut() - .set_size_rule(id, axis, SizeRule::from(len.into())); + self.rsc.widgets_mut().set_len(id, axis, len.into()); } /// Sets the root and lays it out, so a pointer event has something to hit. diff --git a/src/random.rs b/src/random.rs index 99e1812..d628afd 100644 --- a/src/random.rs +++ b/src/random.rs @@ -664,14 +664,15 @@ impl Sow<'_> { /// A length of a box rather than a length of the window, which is what a /// bound is. /// - /// Pixels only, for now. A fraction in a bound is resolved against the rel - /// base the widget was asked with, and `place_at` hands a parent a - /// retained answer without checking that the answer still holds for the - /// rel base this place gives -- so a fraction resolved against one rel - /// base survives into another. Seeds 4 (shuffle-all-but-first) and 196 - /// (resize-size) at depth 5 are where that showed; both pass with pixels. - /// The hole is older than bounds -- an `Exact` rule that is a fraction - /// can reach it too -- and closing it is a check at the re-place site. + /// Pixels, because a fraction is resolved against the rel base the widget + /// was asked with and `deferred_generated.rs` is where that is varied: + /// its relative-bound corpus rewrites these as fractions, so growing them + /// that way here would buy overlap and move every box in the cold dump. + /// Two depth-5 trees once disagreed warm against cold with fractions in + /// them, which is why this was written as pixels; they were named by + /// seed, and seeds stopped naming those trees when the leaves grew + /// images. Re-measured 2026-09-20: 600 depth-5 trees over all sixteen + /// cases agree with every bound here a fraction. fn bound(&mut self) -> Len { Len::px(20.0 + self.rng.below(180) as f32) } diff --git a/tests/deferred_generated.rs b/tests/deferred_generated.rs index 8d3a93a..651c4a8 100644 --- a/tests/deferred_generated.rs +++ b/tests/deferred_generated.rs @@ -6,12 +6,8 @@ use iris::prelude::*; use iris::random::{Edits, Plan, plan}; fn check_requests(edit: impl Fn(&mut Plan) + Sync) { - let count = rig::env("IRIS_DEFERRED_SEEDS", 20_u64); let depth = rig::env("IRIS_DEFERRED_DEPTH", 4_usize); - let seeds = std::env::var("IRIS_DEFERRED_SEED") - .ok() - .and_then(|seed| seed.parse().ok()) - .map_or_else(|| (1..=count).collect(), |seed| vec![seed]); + let seeds = rig::seeds("IRIS_DEFERRED_SEED", "IRIS_DEFERRED_SEEDS", 20); scenario::over_seeds(seeds, |seed| { let mut grown = plan(seed, depth, &Edits::default()); edit(&mut grown); diff --git a/tests/generated.rs b/tests/generated.rs index a99ea51..ccc7a3b 100644 --- a/tests/generated.rs +++ b/tests/generated.rs @@ -113,13 +113,7 @@ fn adding_and_removing_span_children_lands_where_growing_it_that_way_would() { #[ignore = "as many seeds as it is asked for, rather than the ten the others check"] fn a_long_run_of_seeds_agrees() { let depth = depth(); - let seeds: Vec = match std::env::var("IRIS_GENERATED_SEED") - .ok() - .and_then(|v| v.parse().ok()) - { - Some(seed) => vec![seed], - None => (1..=env("IRIS_GENERATED_SEEDS", 100_u64)).collect(), - }; + let seeds = rig::seeds("IRIS_GENERATED_SEED", "IRIS_GENERATED_SEEDS", 100); over_seeds(seeds, |seed| { let grown = plan(seed, depth, &Edits::default()); for case in ALL { diff --git a/tests/rig/mod.rs b/tests/rig/mod.rs index 84fb527..d8542c5 100644 --- a/tests/rig/mod.rs +++ b/tests/rig/mod.rs @@ -10,3 +10,16 @@ pub fn env(name: &str, fallback: T) -> T { .and_then(|value| value.parse().ok()) .unwrap_or(fallback) } + +/// The seeds a scan runs: the one `one` names on its own, or `1..=` the +/// count `many` gives. One seed replaces the range rather than narrowing +/// it, which is how a tree a scan failed on is run again by itself. +// This module is compiled into each rig target separately, so a helper the +// measurement rigs have no seeds to choose is dead code in those builds. +#[allow(dead_code)] +pub fn seeds(one: &str, many: &str, count: u64) -> Vec { + match std::env::var(one).ok().and_then(|seed| seed.parse().ok()) { + Some(seed) => vec![seed], + None => (1..=env(many, count)).collect(), + } +} diff --git a/tests/shrink.rs b/tests/shrink.rs index d6f681b..af0f9bc 100644 --- a/tests/shrink.rs +++ b/tests/shrink.rs @@ -60,13 +60,7 @@ fn cases() -> Vec { fn no_grown_tree_lays_out_differently_warm_than_cold() { let depth: usize = env("SHRINK_DEPTH", 5); let cases = cases(); - let seeds: Vec = match std::env::var("SHRINK_SEED") - .ok() - .and_then(|v| v.parse().ok()) - { - Some(seed) => vec![seed], - None => (1..=env("SHRINK_SEEDS", 400_u64)).collect(), - }; + let seeds = rig::seeds("SHRINK_SEED", "SHRINK_SEEDS", 400); let count = seeds.len(); over_seeds(seeds, |seed| { diff --git a/tests/suite.rs b/tests/suite.rs index 2be9395..5ebef45 100644 --- a/tests/suite.rs +++ b/tests/suite.rs @@ -8,6 +8,8 @@ //! The rigs stay their own targets: `shrink` and `generated` are fuzzers run //! on their own, and the `*_cost` and `*_diagnostics` ones are measurements. +#[path = "cases/deferred.rs"] +mod deferred; #[path = "cases/determinism.rs"] mod determinism; #[path = "cases/drift.rs"] @@ -32,6 +34,3 @@ mod tasks; mod text_edit; #[path = "cases/unsettled.rs"] mod unsettled; - -#[path = "cases/deferred.rs"] -mod deferred;