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;