Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions Dummies.UnitTests/ContinuousExclusionNudgeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#region Usings declarations

using NFluent;

#endregion

namespace Dummies.UnitTests;

/// <summary>
/// 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
/// <see cref="Any.Double" /> guard the shared engine from the other side.
/// </summary>
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<AnyGenerationException>(
() => 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);
}
}

}
47 changes: 33 additions & 14 deletions Dummies/ContinuousIntervalSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
/// </para>
/// <para>
/// 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 <see cref="AnyGenerationException" /> 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 <see cref="AnyGenerationException" /> naming the seed.
/// </para>
/// </remarks>
internal sealed class ContinuousIntervalSpec {
Expand Down Expand Up @@ -67,7 +68,7 @@

#endregion

private ContinuousIntervalSpec(string typeName, Func<double, string> render, Func<double, double> quantize, Func<double, double> nextUp,

Check warning on line 71 in Dummies/ContinuousIntervalSpec.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Constructor has 11 parameters, which is greater than the 7 authorized.
double min, string? minConstraint,
double max, string? maxConstraint,
IReadOnlyList<double>? allowed, string? allowedConstraint,
Expand Down Expand Up @@ -152,7 +153,7 @@
internal long? Cardinality {
get {
if (_effectiveAllowed is not null) { return _effectiveAllowed.Count; }
if (_min == _max) { return 1; }

Check warning on line 156 in Dummies/ContinuousIntervalSpec.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Do not check floating point equality with exact values, use a range instead.

return null;
}
Expand All @@ -175,26 +176,44 @@
return _effectiveAllowed[random.Next(_effectiveAllowed.Count)];
}

if (_min == _max) { return _min; }

Check warning on line 179 in Dummies/ContinuousIntervalSpec.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Do not check floating point equality with exact values, use a range instead.

// Sample around the midpoint so the span (max - min) never overflows to infinity on wide ranges.
double mid = _min / 2 + _max / 2;
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;
}

/// <summary>
/// Walks from <paramref name="from" /> along the type's representable ladder — ascending or descending — to the
/// nearest value the exclusions allow, staying within the bounds. Returns <c>null</c> 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 <c>null</c>.
/// </summary>
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;
Expand All @@ -209,14 +228,14 @@
}

private bool IsExcluded(double value) {
foreach (double excluded in _excluded) {

Check warning on line 231 in Dummies/ContinuousIntervalSpec.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Loops should be simplified using the "Where" LINQ method
if (value.Equals(excluded)) { return true; }
}

return false;
}

private ContinuousIntervalSpec Validated(ContinuousIntervalSpec candidate, string applying) {

Check warning on line 238 in Dummies/ContinuousIntervalSpec.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Make 'Validated' a static method.
if (candidate.IsSatisfiable()) { return candidate; }

throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {candidate.DescribeExhaustion()}.");
Expand Down
Loading