From 029b5abcfcd6184b24c406a35ea8ce51f8e2502c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 14:34:30 +0000 Subject: [PATCH] fix(dummies): honour narrow-range exclusions with a type-aware nudge When a draw collided with an excluded point, ContinuousIntervalSpec stepped with the static double-space NextUp instead of the type-aware _nextUp lambda. On Single and Half one double ulp above a representable value re-quantizes to the same value, so the walk stalled and a satisfiable specification exhausted the nudge budget and threw AnyGenerationException. AnyDouble was immune only because its quantize and nextUp are the identity. Step with the type-aware ladder and walk both directions: ascending first, then descending from the original draw when the ascending walk leaves the bounds, so an exclusion on either bound of a tight range is honoured. Check the next value against the bounds before clamping, so a genuinely exhausted range fails as a real "no value remains" condition rather than by budget exhaustion alone. Add regression tests per continuous type (Single, Half, Double) covering an exclusion inside a narrow range on either bound, a fully excluded range that must still throw a seeded AnyGenerationException, and seeded reproducibility. Refs: #207 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016wCNSRakgsbJXCVcVDNv87 --- .../ContinuousExclusionNudgeTests.cs | 99 +++++++++++++++++++ Dummies/ContinuousIntervalSpec.cs | 47 ++++++--- 2 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 Dummies.UnitTests/ContinuousExclusionNudgeTests.cs diff --git a/Dummies.UnitTests/ContinuousExclusionNudgeTests.cs b/Dummies.UnitTests/ContinuousExclusionNudgeTests.cs new file mode 100644 index 00000000..b6451167 --- /dev/null +++ b/Dummies.UnitTests/ContinuousExclusionNudgeTests.cs @@ -0,0 +1,99 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +/// +/// Regression coverage for issue #207. On the narrow (quantized) floating-point types an exclusion inside a tight +/// range must be honoured by the type-aware nudge — ascending or descending — instead of stalling on a sub-ulp +/// double step and exhausting the budget on a satisfiable specification. The identical scenarios on +/// guard the shared engine from the other side. +/// +public sealed class ContinuousExclusionNudgeTests { + + private const int SeedCount = 500; + + [Fact(DisplayName = "Half: an exclusion on the lower bound of a two-value range yields the surviving value for every seed.")] + public void HalfExclusionOnLowerBound() { + Half min = (Half)1f; + Half max = (Half)1.001f; // rounds to 1.0009765625: the next representable Half above 1.0 + Half survivor = max; + + for (int seed = 0; seed < SeedCount; seed++) { + Half value = Any.WithSeed(seed).Half().Between(min, max).DifferentFrom(min).Generate(); + Check.That(value == survivor).IsTrue(); + } + } + + [Fact(DisplayName = "Half: an exclusion on the upper bound descends to the surviving lower value for every seed.")] + public void HalfExclusionOnUpperBound() { + Half min = (Half)1f; + Half max = (Half)1.001f; + + for (int seed = 0; seed < SeedCount; seed++) { + Half value = Any.WithSeed(seed).Half().Between(min, max).DifferentFrom(max).Generate(); + Check.That(value == min).IsTrue(); + } + } + + [Fact(DisplayName = "Single: an exclusion inside a narrow range never yields the excluded value, either bound, for any seed.")] + public void SingleExclusionInsideNarrowRange() { + float min = 1f; + float max = MathF.BitIncrement(MathF.BitIncrement(1f)); // 1 + 2 ulp: three representable floats in range + + for (int seed = 0; seed < SeedCount; seed++) { + float lower = Any.WithSeed(seed).Single().Between(min, max).DifferentFrom(min).Generate(); + Check.That(lower).IsStrictlyGreaterThan(min); + Check.That(lower).IsLessOrEqualThan(max); + + float upper = Any.WithSeed(seed).Single().Between(min, max).DifferentFrom(max).Generate(); + Check.That(upper).IsStrictlyLessThan(max); + Check.That(upper).IsGreaterOrEqualThan(min); + } + } + + [Fact(DisplayName = "Double: an exclusion inside a narrow range never yields the excluded value, either bound, for any seed.")] + public void DoubleExclusionInsideNarrowRange() { + double min = 1d; + double max = Math.BitIncrement(Math.BitIncrement(1d)); // 1 + 2 ulp: three representable doubles in range + + for (int seed = 0; seed < SeedCount; seed++) { + double lower = Any.WithSeed(seed).Double().Between(min, max).DifferentFrom(min).Generate(); + Check.That(lower).IsStrictlyGreaterThan(min); + Check.That(lower).IsLessOrEqualThan(max); + + double upper = Any.WithSeed(seed).Double().Between(min, max).DifferentFrom(max).Generate(); + Check.That(upper).IsStrictlyLessThan(max); + Check.That(upper).IsGreaterOrEqualThan(min); + } + } + + [Fact(DisplayName = "A range whose every representable value is excluded fails with a seeded AnyGenerationException, not by budget exhaustion.")] + public void ExhaustedRangeThrowsSeededGenerationException() { + Half min = (Half)1f; + Half max = (Half)1.001f; // exactly two representable Half values in [min, max] + + AnyGenerationException thrown = Assert.Throws( + () => Any.WithSeed(207).Half().Between(min, max).Except(min, max).Generate()); + + Check.That(thrown.Seed).IsEqualTo(207); + Check.That(thrown.Message).Contains("207"); + Check.That(thrown.Message).Contains("Any.Reproducibly("); + } + + [Fact(DisplayName = "The nudge stays reproducible: the same seed yields the same value across runs.")] + public void NudgeIsReproducibleForAGivenSeed() { + double min = 1d; + double max = Math.BitIncrement(Math.BitIncrement(1d)); + + for (int seed = 0; seed < 50; seed++) { + double first = Any.WithSeed(seed).Double().Between(min, max).DifferentFrom(min).Generate(); + double second = Any.WithSeed(seed).Double().Between(min, max).DifferentFrom(min).Generate(); + Check.That(second).IsEqualTo(first); + } + } + +} diff --git a/Dummies/ContinuousIntervalSpec.cs b/Dummies/ContinuousIntervalSpec.cs index a4503970..f8ceea8f 100644 --- a/Dummies/ContinuousIntervalSpec.cs +++ b/Dummies/ContinuousIntervalSpec.cs @@ -15,9 +15,10 @@ namespace Dummies; /// /// /// Excluding a point from a continuum can only collide with a draw on a set of measure zero, but the engine -/// still guarantees the constraint: a colliding draw is nudged to the neighbouring representable value, a -/// bounded deterministic walk — not a retry loop. When the walk cannot stay within the bounds the generation -/// fails with an naming the seed. +/// still guarantees the constraint: a colliding draw is nudged to the nearest non-excluded representable +/// value — a bounded deterministic walk along the type's own ladder, ascending then descending from the +/// original draw, not a retry loop. When no representable value in range remains the generation fails with +/// an naming the seed. /// /// internal sealed class ContinuousIntervalSpec { @@ -182,19 +183,37 @@ internal double Generate(Random random, int seed) { double half = _max / 2 - _min / 2; double candidate = Quantized(mid + (2 * random.NextDouble() - 1) * half); - // A draw colliding with an excluded point (a measure-zero event) is walked to the neighbouring - // representable value — deterministic and bounded, not a retry loop. - int budget = NudgeBudget; + // A draw colliding with an excluded point (a measure-zero event) is nudged to the nearest + // non-excluded representable neighbour: ascending first, then descending from the original draw + // when the ascending walk leaves the bounds. Both walks step with the type-aware ladder (_nextUp), + // so on the narrow types a step lands on the next value of their own type instead of stalling on a + // sub-ulp double step that re-quantizes to the same value. + double? free = NudgeToFree(candidate, ascending: true) ?? NudgeToFree(candidate, ascending: false); + if (free is null) { + throw new AnyGenerationException( + $"Generation failed: no {_typeName} value near the drawn candidate satisfies the exclusions. The arbitrary values were seeded with {seed}; reproduce this run with Any.Reproducibly({seed}, ...).", + seed, + new InvalidOperationException("No representable value in range remains after applying the exclusions.")); + } + + return free.Value; + } + + /// + /// Walks from along the type's representable ladder — ascending or descending — to the + /// nearest value the exclusions allow, staying within the bounds. Returns null when the walk reaches a + /// bound before finding one, so the caller can try the opposite direction; a genuinely exhausted range is the + /// case where both directions return null. + /// + private double? NudgeToFree(double from, bool ascending) { + double candidate = from; + int budget = NudgeBudget; while (IsExcluded(candidate)) { - double next = Quantized(NextUp(candidate)); - if (next > _max || budget-- == 0) { - throw new AnyGenerationException( - $"Generation failed: no {_typeName} value near the drawn candidate satisfies the exclusions. The arbitrary values were seeded with {seed}; reproduce this run with Any.Reproducibly({seed}, ...).", - seed, - new InvalidOperationException("The exclusion nudge walked out of the allowed range.")); - } + // The type-aware next-up / next-down: -_nextUp(-x) mirrors the ascending step onto the descending ladder. + double next = ascending ? _nextUp(candidate) : -_nextUp(-candidate); + if (next < _min || next > _max || budget-- == 0) { return null; } - candidate = next; + candidate = Quantized(next); } return candidate;