diff --git a/Dummies.UnitTests/AnyCollectionTests.cs b/Dummies.UnitTests/AnyCollectionTests.cs index f776fd74..10d24726 100644 --- a/Dummies.UnitTests/AnyCollectionTests.cs +++ b/Dummies.UnitTests/AnyCollectionTests.cs @@ -147,6 +147,147 @@ public void ContainingFromAGenerator() { } } + [Fact(DisplayName = "Containing: a fixed value outside the element domain extends the effective cardinality (issue #188).")] + public void ContainingOutsideDomainExtendsCardinality() { + // The motivating case: {1, 2, 3} is satisfiable — 3 is supplied directly and lies outside the {1, 2} the + // generator can produce, so only two elements must be drawn from it. + for (int i = 0; i < SampleCount; i++) { + HashSet set = Any.SetOf(Any.Int32().OneOf(1, 2)).Containing(3).WithCount(3).Generate(); + Check.That(set).Contains(1, 2, 3); + Check.That(set.Count).IsEqualTo(3); + } + + // The same reasoning holds for a distinct list and for several out-of-domain values at once. Dictionary keys + // run through the very same CollectionState path, so the correction reaches them too — but AnyDictionary has + // no Containing surface, so its keys are gated purely by count (see DictionaryOfBehaves) and cannot exercise + // the out-of-domain case directly. + for (int i = 0; i < SampleCount; i++) { + HashSet list = new(Any.ListOf(Any.Int32().OneOf(1, 2)).Containing(3).WithCount(3).Distinct().Generate()); + Check.That(list).Contains(1, 2, 3); + + HashSet quad = Any.SetOf(Any.Int32().OneOf(1, 2)).Containing(3).Containing(4).WithCount(4).Generate(); + Check.That(quad).Contains(1, 2, 3, 4); + Check.That(quad.Count).IsEqualTo(4); + } + } + + [Fact(DisplayName = "Containing: a value already inside the element domain does not inflate the cardinality, so an impossible count still conflicts.")] + public void ContainingInsideDomainDoesNotInflate() { + // 1 is already producible by the generator, so it adds no capacity: three distinct values over {1, 2} remain + // impossible and must still fail eagerly, naming the shortfall. + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.SetOf(Any.Int32().OneOf(1, 2)).Containing(1).WithCount(3)); + Check.That(conflict.Message).Contains("2 distinct value"); + + // Mixed: 1 is inside the domain, 5 is outside — effective capacity is 2 + 1 = 3. Four is over the top; three + // is exactly reachable as {1, 2, 5}. + Check.ThatCode(() => Any.SetOf(Any.Int32().OneOf(1, 2)).Containing(1).Containing(5).WithCount(4)).Throws(); + for (int i = 0; i < SampleCount; i++) { + HashSet set = Any.SetOf(Any.Int32().OneOf(1, 2)).Containing(1).Containing(5).WithCount(3).Generate(); + Check.That(set).Contains(1, 2, 5); + } + } + + [Fact(DisplayName = "Containing: the effective cardinality is order-independent across Distinct, Containing and the count.")] + public void EffectiveCardinalityIsOrderIndependent() { + // Every ordering of the same three constraints reaches the same verdict — accepted, because 3 is outside the + // domain — since Distinct() re-runs the whole validation on the accumulated state. + for (int i = 0; i < SampleCount; i++) { + Check.That(new HashSet(Any.ListOf(Any.Int32().OneOf(1, 2)).WithCount(3).Containing(3).Distinct().Generate())).Contains(1, 2, 3); + Check.That(new HashSet(Any.ListOf(Any.Int32().OneOf(1, 2)).Distinct().Containing(3).WithCount(3).Generate())).Contains(1, 2, 3); + Check.That(new HashSet(Any.ListOf(Any.Int32().OneOf(1, 2)).Containing(3).WithCount(3).Distinct().Generate())).Contains(1, 2, 3); + } + + // And rejected whatever the order, because the contained value is inside the domain. + Check.ThatCode(() => Any.ListOf(Any.Int32().OneOf(1, 2)).WithCount(3).Containing(1).Distinct()).Throws(); + Check.ThatCode(() => Any.ListOf(Any.Int32().OneOf(1, 2)).Distinct().Containing(1).WithCount(3)).Throws(); + Check.ThatCode(() => Any.ListOf(Any.Int32().OneOf(1, 2)).Containing(1).WithCount(3).Distinct()).Throws(); + } + + [Fact(DisplayName = "Containing: a near-maximum element cardinality plus an out-of-domain value does not overflow into a false conflict.")] + public void ContainingNearMaximumCardinalityDoesNotOverflow() { + // Between(0, long.MaxValue - 1) advertises long.MaxValue distinct values; the additive form base + extras + // would overflow to a negative and reject spuriously. The subtractive check stays correct. + for (int i = 0; i < SampleCount; i++) { + HashSet set = Any.SetOf(Any.Int64().Between(0, long.MaxValue - 1)).Containing(-1L).WithCount(3).Generate(); + Check.That(set).Contains(-1L); + Check.That(set.Count).IsEqualTo(3); + } + } + + [Fact(DisplayName = "ContainingAny stays conservative: no eager false conflict, and an opaque shortfall surfaces at generation.")] + public void ContainingAnyDefersToGeneration() { + // The generator drawn from can yield a value outside the element domain, so the request cannot be proven + // impossible at declaration — a wide ContainingAny makes it genuinely satisfiable. + for (int i = 0; i < SampleCount; i++) { + HashSet set = Any.SetOf(Any.Int32().OneOf(1, 2)).ContainingAny(Any.Int32().GreaterThan(100)).WithCount(3).Generate(); + Check.That(set.Count).IsEqualTo(3); + Check.That(set).Contains(1, 2); + } + + // When every source draws from the same two-value domain, three distinct values are impossible — but the + // overlap is opaque, so it is caught while drawing (a replayable AnyGenerationException) rather than as a + // false eager conflict. + Check.ThatCode(() => Any.SetOf(Any.Bool()).ContainingAny(Any.Bool()).ContainingAny(Any.Bool()).ContainingAny(Any.Bool()).Generate()) + .Throws(); + } + + [Fact(DisplayName = "Containing under a merging comparer: an out-of-domain value is credited, and a comparer that merges it back is caught at generation.")] + public void ContainingUnderAMergingComparer() { + IEqualityComparer modTen = new ModuloComparer(10); + + // 15 is outside {1, 2, 3} and its residue class (5) is fresh too, so {1, 2, 3, 15} has four classes and + // generation succeeds. + for (int i = 0; i < SampleCount; i++) { + HashSet set = Any.SetOf(Any.Int32().OneOf(1, 2, 3), modTen).Containing(15).WithCount(4).Generate(); + Check.That(set.Count).IsEqualTo(4); + } + + // 12 is outside {1, 2} by value, so it is still credited and the request is accepted eagerly — but 12 ≡ 2 + // (mod 10) collapses it back into the domain, so three distinct-under-the-comparer values are impossible and + // the shortfall surfaces while drawing, never as a false eager conflict. + Check.ThatCode(() => Any.SetOf(Any.Int32().OneOf(1, 2), modTen).Containing(12).WithCount(3).Generate()).Throws(); + } + + [Fact(DisplayName = "The eager perimeter reaches every finite generator: decimal, floating-point and 128-bit allow-lists gate distinct collections too.")] + public void FiniteScalarGeneratorsGateEagerly() { + // A finite allow-list or a narrow range over decimal, double, single or Int128 now advertises its cardinality, + // so a count beyond it conflicts at declaration — the same promise integers and enums already kept, held + // across the whole knowable perimeter rather than only part of it. + Check.ThatCode(() => Any.SetOf(Any.Decimal().OneOf(1m, 2m)).WithCount(3)).Throws(); + Check.ThatCode(() => Any.SetOf(Any.Double().OneOf(1d, 2d)).WithCount(3)).Throws(); + Check.ThatCode(() => Any.SetOf(Any.Single().OneOf(1f, 2f)).WithCount(3)).Throws(); + Check.ThatCode(() => Any.SetOf(Any.Int128().Between(1, 3)).WithCount(5)).Throws(); + + // Membership travels with cardinality: an out-of-domain contained value extends the effective domain... + for (int i = 0; i < SampleCount; i++) { + HashSet set = Any.SetOf(Any.Decimal().OneOf(1m, 2m)).Containing(3m).WithCount(3).Generate(); + Check.That(set).Contains(1m, 2m, 3m); + } + + // ...while a contained value already inside it does not, so an impossible count still conflicts eagerly. + Check.ThatCode(() => Any.SetOf(Any.Decimal().OneOf(1m, 2m)).Containing(1m).WithCount(3)).Throws(); + Check.ThatCode(() => Any.SetOf(Any.Double().OneOf(1d, 2d)).Containing(2d).WithCount(3)).Throws(); + } + + [Fact(DisplayName = "A validated pin is a singleton domain: a distinct collection asking for more than one conflicts eagerly.")] + public void SingletonScalarDomainsGateEagerly() { + // Zero()/Between(x, x) pins the domain to a single value; asking a distinct collection for two is a fully + // knowable contradiction, so it must fail at declaration, not only while drawing. + Check.ThatCode(() => Any.SetOf(Any.Decimal().Zero()).WithCount(2)).Throws(); + Check.ThatCode(() => Any.SetOf(Any.Double().Between(1d, 1d)).WithCount(2)).Throws(); + Check.ThatCode(() => Any.SetOf(Any.Single().Zero()).WithCount(2)).Throws(); + + // The singleton still generates at count one, and an out-of-domain contained value extends it as usual. + for (int i = 0; i < SampleCount; i++) { + HashSet one = Any.SetOf(Any.Decimal().Zero()).WithCount(1).Generate(); + Check.That(one).ContainsExactly(0m); + + HashSet two = Any.SetOf(Any.Decimal().Zero()).Containing(5m).WithCount(2).Generate(); + Check.That(two).Contains(0m, 5m); + } + } + [Fact(DisplayName = "ArrayOf: produces an array of the requested size, distinct when asked.")] public void ArrayOfProducesArrays() { for (int i = 0; i < SampleCount; i++) { diff --git a/Dummies/AnyBool.cs b/Dummies/AnyBool.cs index f1c3913c..c1b44b85 100644 --- a/Dummies/AnyBool.cs +++ b/Dummies/AnyBool.cs @@ -5,7 +5,7 @@ namespace Dummies; /// mostly useful for symmetry when a test sweeps cases — and contradictory pins fail eagerly with a /// naming both sides, like every other generator. /// -public sealed class AnyBool : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyBool : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -36,7 +36,10 @@ private AnyBool(RandomSource source, bool? pinned, string? pinnedConstraint) { RandomSource? IHasRandomSource.Source => _source; // Two distinct values unless a pin has already fixed one of them. - long? ICardinalityHint.DistinctCardinality => _pinned is null ? 2 : 1; + long? ICardinalityHint.DistinctCardinality => _pinned is null ? 2 : 1; + + // A pin narrows the domain to that single value; unpinned, both booleans are producible. + bool ICardinalityHint.Contains(bool value) => _pinned is not bool pinned || pinned == value; /// Pins the value to true. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyByte.cs b/Dummies/AnyByte.cs index 5f75b1f2..3fa12ccd 100644 --- a/Dummies/AnyByte.cs +++ b/Dummies/AnyByte.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyByte : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyByte : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -52,7 +52,9 @@ private AnyByte(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(byte value) => _spec.Contains(Ord(value)); /// Pins the value to exactly zero. Useful for symmetry with the other constraints when a test sweeps cases. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyChar.cs b/Dummies/AnyChar.cs index 56ce746b..b6e0dc7a 100644 --- a/Dummies/AnyChar.cs +++ b/Dummies/AnyChar.cs @@ -8,7 +8,7 @@ namespace Dummies; /// . A combination that empties the pool fails eagerly with a /// . /// -public sealed class AnyChar : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyChar : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -63,7 +63,10 @@ private AnyChar(RandomSource source, RandomSource? IHasRandomSource.Source => _source; // The pool is materialized once at construction, so its size is the exact number of characters drawable. - long? ICardinalityHint.DistinctCardinality => _pool.Count; + long? ICardinalityHint.DistinctCardinality => _pool.Count; + + // The pool is the exact draw set, so membership is a direct pool lookup. + bool ICardinalityHint.Contains(char value) => _pool.Contains(value); /// Restricts the character to ASCII letters only. Declared once per generator. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyDateOnly.cs b/Dummies/AnyDateOnly.cs index a8c8f449..f613d8b4 100644 --- a/Dummies/AnyDateOnly.cs +++ b/Dummies/AnyDateOnly.cs @@ -16,7 +16,7 @@ namespace Dummies; /// constraint: a reproducible test pins its reference dates explicitly with and /// . /// -public sealed class AnyDateOnly : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyDateOnly : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -56,7 +56,9 @@ private AnyDateOnly(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(DateOnly value) => _spec.Contains(Ord(value)); /// Requires a date strictly after . /// The exclusive lower bound. diff --git a/Dummies/AnyDateTime.cs b/Dummies/AnyDateTime.cs index 80402520..89a5c495 100644 --- a/Dummies/AnyDateTime.cs +++ b/Dummies/AnyDateTime.cs @@ -19,7 +19,7 @@ namespace Dummies; /// comparison operators do. Values supplied to are returned as given, Kind included. There is deliberately no clock-relative constraint (no "in the past/future"): a /// reproducible test pins its reference instants explicitly with and . /// -public sealed class AnyDateTime : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyDateTime : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -61,7 +61,9 @@ private AnyDateTime(RandomSource source, OrdinalIntervalSpec spec, IReadOnlyDict RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(DateTime value) => _spec.Contains(Ord(value)); /// Requires an instant strictly after . /// The exclusive lower bound. diff --git a/Dummies/AnyDateTimeOffset.cs b/Dummies/AnyDateTimeOffset.cs index 0859094d..982fd2a3 100644 --- a/Dummies/AnyDateTimeOffset.cs +++ b/Dummies/AnyDateTimeOffset.cs @@ -21,7 +21,7 @@ namespace Dummies; /// past/future"): a reproducible test pins its reference instants explicitly with and /// . /// -public sealed class AnyDateTimeOffset : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyDateTimeOffset : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -63,7 +63,9 @@ private AnyDateTimeOffset(RandomSource source, OrdinalIntervalSpec spec, IReadOn RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(DateTimeOffset value) => _spec.Contains(Ord(value)); /// Requires an instant strictly after . /// The exclusive lower bound. diff --git a/Dummies/AnyDecimal.cs b/Dummies/AnyDecimal.cs index b8d93293..f90696fc 100644 --- a/Dummies/AnyDecimal.cs +++ b/Dummies/AnyDecimal.cs @@ -13,7 +13,7 @@ namespace Dummies; /// sides; instances are immutable recipes. Exclusive bounds are expressed as the inclusive bound plus a point /// exclusion, since has no next-representable-value ladder. /// -public sealed class AnyDecimal : IAny, IHasRandomSource { +public sealed class AnyDecimal : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -45,6 +45,10 @@ private AnyDecimal(RandomSource source, DecimalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(decimal value) => _spec.Contains(value); + /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyDerivation.cs b/Dummies/AnyDerivation.cs index 6663bdcb..a67d9d45 100644 --- a/Dummies/AnyDerivation.cs +++ b/Dummies/AnyDerivation.cs @@ -45,10 +45,10 @@ internal static class AnyDerivation { /// /// A conservative upper bound on the number of distinct values yields, when it - /// advertises one through ; null when the domain is unbounded or unknown. + /// advertises one through ; null when the domain is unbounded or unknown. /// internal static long? CardinalityOf(IAny generator) { - return (generator as ICardinalityHint)?.DistinctCardinality; + return (generator as ICardinalityHint)?.DistinctCardinality; } /// diff --git a/Dummies/AnyDouble.cs b/Dummies/AnyDouble.cs index 3c8f3586..2dadb182 100644 --- a/Dummies/AnyDouble.cs +++ b/Dummies/AnyDouble.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes. NaN and the infinities are never generated nor accepted. /// -public sealed class AnyDouble : IAny, IHasRandomSource { +public sealed class AnyDouble : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -44,6 +44,10 @@ private AnyDouble(RandomSource source, ContinuousIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(double value) => _spec.Contains(value); + /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyEnum.cs b/Dummies/AnyEnum.cs index 4f0d0df1..52cc6510 100644 --- a/Dummies/AnyEnum.cs +++ b/Dummies/AnyEnum.cs @@ -7,7 +7,7 @@ namespace Dummies; /// fails eagerly with a naming both sides. /// /// The enum type to draw values from. -public sealed class AnyEnum : IAny, IHasRandomSource, ICardinalityHint +public sealed class AnyEnum : IAny, IHasRandomSource, ICardinalityHint where TEnum : struct, Enum { #region Statics members declarations @@ -59,7 +59,10 @@ private AnyEnum(RandomSource source, IReadOnlyList declared, RandomSource? IHasRandomSource.Source => _source; // The pool is materialized once at construction, so its size is the exact number of members drawable. - long? ICardinalityHint.DistinctCardinality => _pool.Count; + long? ICardinalityHint.DistinctCardinality => _pool.Count; + + // The pool is the exact draw set, so membership is a direct pool lookup. + bool ICardinalityHint.Contains(TEnum value) => _pool.Contains(value); /// Requires the value to be one of the supplied members. Declared once per generator. /// The allowed members; duplicates are ignored. Every value must be a declared member of — the generator never yields undeclared numeric values, not even explicitly supplied ones. diff --git a/Dummies/AnyGuid.cs b/Dummies/AnyGuid.cs index 97c2a8e0..ef6742c6 100644 --- a/Dummies/AnyGuid.cs +++ b/Dummies/AnyGuid.cs @@ -8,7 +8,7 @@ namespace Dummies; /// to pin the empty identifier. Contradictory constraints fail eagerly with a /// naming both sides. /// -public sealed class AnyGuid : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyGuid : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -66,7 +66,15 @@ private AnyGuid(RandomSource source, Guid? pinned, string? pinnedConstraint, RandomSource? IHasRandomSource.Source => _source; // Pinned to a single value, or bounded by an allow-list; otherwise the domain is effectively unbounded. - long? ICardinalityHint.DistinctCardinality => _pinned is not null ? 1 : _effectiveAllowed?.Count; + long? ICardinalityHint.DistinctCardinality => _pinned is not null ? 1 : _effectiveAllowed?.Count; + + // Mirrors Generate: the pin, then the allow-list, then the full space minus the exclusions. + bool ICardinalityHint.Contains(Guid value) { + if (_pinned is Guid pinned) { return pinned == value; } + if (_effectiveAllowed is not null) { return _effectiveAllowed.Contains(value); } + + return !_excluded.Contains(value); + } /// Requires an identifier different from . /// A new generator carrying the added constraint. diff --git a/Dummies/AnyHalf.cs b/Dummies/AnyHalf.cs index 21fc1cc3..e7efc5bc 100644 --- a/Dummies/AnyHalf.cs +++ b/Dummies/AnyHalf.cs @@ -14,7 +14,7 @@ namespace Dummies; /// sides; instances are immutable recipes. NaN and the infinities are never generated nor accepted. Available on /// the net8.0 target only, like the type itself. /// -public sealed class AnyHalf : IAny, IHasRandomSource { +public sealed class AnyHalf : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -56,6 +56,11 @@ private AnyHalf(RandomSource source, ContinuousIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + // The allow-list holds the doubles the supplied halves widen to, so membership tests the same widening. + bool ICardinalityHint.Contains(Half value) => _spec.Contains((double)value); + /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyInt128.cs b/Dummies/AnyInt128.cs index 1f040036..96eb0de5 100644 --- a/Dummies/AnyInt128.cs +++ b/Dummies/AnyInt128.cs @@ -14,7 +14,7 @@ namespace Dummies; /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// Available on the net8.0 target only, like the type itself. /// -public sealed class AnyInt128 : IAny, IHasRandomSource { +public sealed class AnyInt128 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -54,6 +54,10 @@ private AnyInt128(RandomSource source, WideIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(Int128 value) => _spec.Contains(Ord(value)); + /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyInt16.cs b/Dummies/AnyInt16.cs index 1cc212ee..11b141b8 100644 --- a/Dummies/AnyInt16.cs +++ b/Dummies/AnyInt16.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyInt16 : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyInt16 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -52,7 +52,9 @@ private AnyInt16(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(short value) => _spec.Contains(Ord(value)); /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyInt32.cs b/Dummies/AnyInt32.cs index c8169f90..188e1be0 100644 --- a/Dummies/AnyInt32.cs +++ b/Dummies/AnyInt32.cs @@ -28,7 +28,7 @@ namespace Dummies; /// /// /// -public sealed class AnyInt32 : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyInt32 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -68,7 +68,9 @@ private AnyInt32(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(int value) => _spec.Contains(Ord(value)); /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyInt64.cs b/Dummies/AnyInt64.cs index a64bd925..2ad73622 100644 --- a/Dummies/AnyInt64.cs +++ b/Dummies/AnyInt64.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyInt64 : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyInt64 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -52,7 +52,9 @@ private AnyInt64(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(long value) => _spec.Contains(Ord(value)); /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. diff --git a/Dummies/AnySByte.cs b/Dummies/AnySByte.cs index 9647ad62..0ef14ae0 100644 --- a/Dummies/AnySByte.cs +++ b/Dummies/AnySByte.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnySByte : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnySByte : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -52,7 +52,9 @@ private AnySByte(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(sbyte value) => _spec.Contains(Ord(value)); /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. diff --git a/Dummies/AnySingle.cs b/Dummies/AnySingle.cs index 499384ec..bede8742 100644 --- a/Dummies/AnySingle.cs +++ b/Dummies/AnySingle.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes. NaN and the infinities are never generated nor accepted. /// -public sealed class AnySingle : IAny, IHasRandomSource { +public sealed class AnySingle : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -54,6 +54,11 @@ private AnySingle(RandomSource source, ContinuousIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + // The allow-list holds the doubles the supplied floats widen to, so membership tests the same widening. + bool ICardinalityHint.Contains(float value) => _spec.Contains((double)value); + /// Requires a value strictly greater than zero. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyTimeOnly.cs b/Dummies/AnyTimeOnly.cs index d72960f4..c7f5d6b6 100644 --- a/Dummies/AnyTimeOnly.cs +++ b/Dummies/AnyTimeOnly.cs @@ -16,7 +16,7 @@ namespace Dummies; /// constraint: a reproducible test pins its reference time of days explicitly with and /// . /// -public sealed class AnyTimeOnly : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyTimeOnly : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -56,7 +56,9 @@ private AnyTimeOnly(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(TimeOnly value) => _spec.Contains(Ord(value)); /// Requires a time of day strictly after . /// The exclusive lower bound. diff --git a/Dummies/AnyTimeSpan.cs b/Dummies/AnyTimeSpan.cs index b6edb664..69423a79 100644 --- a/Dummies/AnyTimeSpan.cs +++ b/Dummies/AnyTimeSpan.cs @@ -13,7 +13,7 @@ namespace Dummies; /// naming both sides; instances are immutable recipes, and each value is built to satisfy the constraints in one /// draw. Unconstrained, it draws from the full range, negative durations included. /// -public sealed class AnyTimeSpan : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyTimeSpan : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -53,7 +53,9 @@ private AnyTimeSpan(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(TimeSpan value) => _spec.Contains(Ord(value)); /// Requires a duration strictly greater than . /// A new generator carrying the added constraint. diff --git a/Dummies/AnyUInt128.cs b/Dummies/AnyUInt128.cs index d1617c1b..77be9bb6 100644 --- a/Dummies/AnyUInt128.cs +++ b/Dummies/AnyUInt128.cs @@ -14,7 +14,7 @@ namespace Dummies; /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// Available on the net8.0 target only, like the type itself. /// -public sealed class AnyUInt128 : IAny, IHasRandomSource { +public sealed class AnyUInt128 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -54,6 +54,10 @@ private AnyUInt128(RandomSource source, WideIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(UInt128 value) => _spec.Contains(Ord(value)); + /// Pins the value to exactly zero. Useful for symmetry with the other constraints when a test sweeps cases. /// A new generator carrying the added constraint. /// Thrown when the constraint contradicts a constraint already declared. diff --git a/Dummies/AnyUInt16.cs b/Dummies/AnyUInt16.cs index 2586729b..ce2a7b94 100644 --- a/Dummies/AnyUInt16.cs +++ b/Dummies/AnyUInt16.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyUInt16 : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyUInt16 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -52,7 +52,9 @@ private AnyUInt16(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(ushort value) => _spec.Contains(Ord(value)); /// Pins the value to exactly zero. Useful for symmetry with the other constraints when a test sweeps cases. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyUInt32.cs b/Dummies/AnyUInt32.cs index c1268f7a..92f41085 100644 --- a/Dummies/AnyUInt32.cs +++ b/Dummies/AnyUInt32.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyUInt32 : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyUInt32 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -52,7 +52,9 @@ private AnyUInt32(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(uint value) => _spec.Contains(Ord(value)); /// Pins the value to exactly zero. Useful for symmetry with the other constraints when a test sweeps cases. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyUInt64.cs b/Dummies/AnyUInt64.cs index fec29a3b..61b24eac 100644 --- a/Dummies/AnyUInt64.cs +++ b/Dummies/AnyUInt64.cs @@ -12,7 +12,7 @@ namespace Dummies; /// contradictory constraints fail eagerly with a naming both /// sides; instances are immutable recipes, and each value is built to satisfy the constraints in one draw. /// -public sealed class AnyUInt64 : IAny, IHasRandomSource, ICardinalityHint { +public sealed class AnyUInt64 : IAny, IHasRandomSource, ICardinalityHint { #region Statics members declarations @@ -52,7 +52,9 @@ private AnyUInt64(RandomSource source, OrdinalIntervalSpec spec) { RandomSource? IHasRandomSource.Source => _source; - long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + long? ICardinalityHint.DistinctCardinality => _spec.Cardinality; + + bool ICardinalityHint.Contains(ulong value) => _spec.Contains(Ord(value)); /// Pins the value to exactly zero. Useful for symmetry with the other constraints when a test sweeps cases. /// A new generator carrying the added constraint. diff --git a/Dummies/CollectionState.cs b/Dummies/CollectionState.cs index 5586e3bc..ddba71b9 100644 --- a/Dummies/CollectionState.cs +++ b/Dummies/CollectionState.cs @@ -16,10 +16,12 @@ namespace Dummies; /// /// /// Distinctness follows the two-layer contract the library commits to: when the element generator advertises a -/// small cardinality that a requested count would exceed, the conflict is -/// caught at declaration time (); otherwise the count is drawn and -/// the elements are filled by a bounded dedup-draw, and a genuine shortfall surfaces at generation as an -/// naming the seed to replay. +/// small cardinality that the requested count would exceed — counting only +/// the elements that must be drawn from that generator, since values pinned with Containing(...) outside +/// its domain (see ) are supplied directly and extend the effective domain — +/// the conflict is caught at declaration time (); otherwise the +/// count is drawn and the elements are filled by a bounded dedup-draw, and a genuine shortfall surfaces at +/// generation as an naming the seed to replay. /// /// The element type. internal sealed class CollectionState { @@ -166,15 +168,46 @@ private void Validate(string applying) { } if (_itemCardinality is long cardinality) { - int need = Math.Max(_count.Floor, required); - if (need > cardinality) { - throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {Elements(need)} required to be distinct exceed the {cardinality.ToString(CultureInfo.InvariantCulture)} distinct value(s) the element generator can produce."); + // Only the elements that must be drawn from the generator count against its cardinality: values pinned + // outside its domain occupy their own slots, and each opaque ContainingAny draw is credited as if it + // could land outside too (conservative — an unprovable overlap defers to the bounded draw rather than + // a false conflict). The subtractive form keeps the left side within int, so a near-long.MaxValue + // domain never overflows the comparison. + int need = Math.Max(_count.Floor, required); + int fromGenerator = need - FixedOutsideCount() - _generatedContaining.Count; + if (fromGenerator > cardinality) { + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {Elements(fromGenerator)} required to be distinct exceed the {cardinality.ToString(CultureInfo.InvariantCulture)} distinct value(s) the element generator can produce."); } } } private int? CardinalityCap() { - return _itemCardinality is long cardinality && cardinality <= int.MaxValue ? (int)cardinality : null; + // The effective ceiling is the generator's own cardinality plus the values pinned outside its domain, which + // fill their own slots without drawing on it — so a distinct collection over a small domain can still reach + // the size those extra values allow. Guard the cast: a domain wider than int cannot cap an int count anyway, + // and once the generator's cardinality is int-bounded the add stays within long. + if (_itemCardinality is not long cardinality || cardinality > int.MaxValue) { return null; } + + long effective = cardinality + FixedOutsideCount(); + + return effective <= int.MaxValue ? (int)effective : null; + } + + private int FixedOutsideCount() { + if (_fixedContaining.Count == 0) { return 0; } + // A fixed value the element generator could never produce extends the effective distinct domain; a value + // already inside it does not. The cardinality snapshot came from this same generator, so whenever the eager + // check runs it also answers membership (cardinality and membership are one interface). The null branch is a + // defensive fallback: treat every fixed value as outside, so the check can only defer to the bounded draw, + // never falsely reject. + if (_item is not ICardinalityHint hint) { return _fixedContaining.Count; } + + int outside = 0; + foreach (T value in _fixedContaining) { + if (!hint.Contains(value)) { outside++; } + } + + return outside; } private T DrawFresh(IAny generator, HashSet seen, RandomSource source, int target) { diff --git a/Dummies/ContinuousIntervalSpec.cs b/Dummies/ContinuousIntervalSpec.cs index b03898e7..a4503970 100644 --- a/Dummies/ContinuousIntervalSpec.cs +++ b/Dummies/ContinuousIntervalSpec.cs @@ -142,6 +142,33 @@ internal ContinuousIntervalSpec WithExcluded(double[] values, string applying) { return Validated(new ContinuousIntervalSpec(_typeName, _render, _quantize, _nextUp, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded), applying); } + /// + /// The number of distinct values the specification can produce — the allow-list size when one is set, 1 + /// for a validated pin (_min == _max, a singleton domain), and null otherwise: a floating-point + /// range is treated as a continuum (counting its representable values is a type-specific concern the shared + /// engine does not carry), so it stays outside the eager cardinality perimeter and a distinct collection over + /// it falls back to the bounded draw. Feeds . + /// + internal long? Cardinality { + get { + if (_effectiveAllowed is not null) { return _effectiveAllowed.Count; } + if (_min == _max) { return 1; } + + return null; + } + } + + /// + /// Whether is one the specification could produce — a member of the allow-list when + /// one is set, otherwise inside the interval and not excluded. Non-finite inputs fall outside the bounds and + /// so return false. Mirrors 's own domain. + /// + internal bool Contains(double value) { + if (_effectiveAllowed is not null) { return _effectiveAllowed.Contains(value); } + + return value >= _min && value <= _max && !IsExcluded(value); + } + /// Draws one value satisfying the whole specification. internal double Generate(Random random, int seed) { if (_effectiveAllowed is not null) { diff --git a/Dummies/DecimalIntervalSpec.cs b/Dummies/DecimalIntervalSpec.cs index 871a528e..a0e888a5 100644 --- a/Dummies/DecimalIntervalSpec.cs +++ b/Dummies/DecimalIntervalSpec.cs @@ -107,6 +107,32 @@ internal DecimalIntervalSpec WithExcluded(decimal[] values, string applying) { return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded), applying); } + /// + /// The number of distinct values the specification can produce — the allow-list size when one is set, 1 + /// for a validated pin (_min == _max, a singleton domain), and null otherwise: a wider + /// interval is a countable domain in theory but astronomically large, so it stays + /// outside the eager cardinality perimeter and a distinct collection over it falls back to the bounded draw. + /// Feeds . + /// + internal long? Cardinality { + get { + if (_effectiveAllowed is not null) { return _effectiveAllowed.Count; } + if (_min == _max) { return 1; } + + return null; + } + } + + /// + /// Whether is one the specification could produce — a member of the allow-list when + /// one is set, otherwise inside the interval and not excluded. Mirrors 's own domain. + /// + internal bool Contains(decimal value) { + if (_effectiveAllowed is not null) { return _effectiveAllowed.Contains(value); } + + return value >= _min && value <= _max && !IsExcluded(value); + } + /// Draws one value satisfying the whole specification. internal decimal Generate(Random random, int seed) { if (_effectiveAllowed is not null) { diff --git a/Dummies/ICardinalityHint.cs b/Dummies/ICardinalityHint.cs index 54b64ac4..36e85a90 100644 --- a/Dummies/ICardinalityHint.cs +++ b/Dummies/ICardinalityHint.cs @@ -3,21 +3,36 @@ namespace Dummies; /// /// Implemented by the library's own generators that draw from a small, countable domain, so a distinct /// collection (, ListOf(...).Distinct(), a dictionary's keys) can tell — at -/// declaration time — that it is being asked for more distinct elements than the element generator could ever -/// produce, and fail eagerly with a instead of only discovering -/// it while drawing. +/// declaration time — whether a requested count, together with any values pinned through Containing(...), +/// can be satisfied from the effective domain, and fail eagerly with a +/// instead of only discovering it while drawing. /// /// -/// The value is a conservative upper bound on the number of distinct elements the generator yields. A -/// generator whose domain is unbounded, effectively unbounded, or simply unknown (a foreign -/// , a derived generator) does not implement this interface — the collection then relies on -/// the bounded dedup-draw fallback, which surfaces a genuine shortfall as an . -/// Because the bound ignores any custom (which can only merge values, -/// never create new ones), it stays a sound upper bound under a comparer too. +/// The two members travel together on purpose — that is the whole point of putting them on one interface: +/// answers "how many distinct values can the generator produce" (a +/// conservative upper bound), and answers "is this one of them". A distinct +/// collection needs both: the size to gate the count, and membership to tell a contained value that +/// extends the domain (one the generator could never draw) from one already inside it. Because they are a +/// single contract, a generator cannot advertise a cardinality without also answering membership — the compiler +/// keeps the promise, so no generator can drift out of the eager perimeter unnoticed. +/// +/// A generator whose domain is unbounded, effectively unbounded, or simply unknown (a foreign +/// , a derived generator) does not implement this interface; the collection then relies +/// on the bounded dedup-draw fallback, which surfaces a genuine shortfall as an +/// . Because both the bound and membership ignore any custom +/// (which can only merge values, never create new ones), they stay +/// sound under a comparer too. +/// /// -internal interface ICardinalityHint { +/// The element type. +internal interface ICardinalityHint { /// The number of distinct values the generator can produce, or null when that is unbounded or unknown. long? DistinctCardinality { get; } + /// Whether the generator, as constrained, could ever produce . + /// The candidate value. + /// true when is within the generator's domain; otherwise false. + bool Contains(T value); + } diff --git a/Dummies/OrdinalIntervalSpec.cs b/Dummies/OrdinalIntervalSpec.cs index cc0246cd..437d20ed 100644 --- a/Dummies/OrdinalIntervalSpec.cs +++ b/Dummies/OrdinalIntervalSpec.cs @@ -149,7 +149,7 @@ internal OrdinalIntervalSpec WithExcluded(ulong[] ordinals, string applying) { /// /// The number of distinct values the specification can produce, or null when that exceeds /// (a range too wide to ever conflict with a collection count). Feeds - /// , so a distinct collection over a narrow integer range can fail eagerly. + /// , so a distinct collection over a narrow integer range can fail eagerly. /// internal long? Cardinality { get { @@ -162,6 +162,18 @@ internal long? Cardinality { } } + /// + /// Whether is a value the specification could produce — the exact domain + /// draws from: a member of the allow-list when one is set, otherwise inside + /// the interval and not excluded. Feeds , so a distinct collection can tell + /// a contained value that extends the domain from one already inside it. + /// + internal bool Contains(ulong ordinal) { + if (_effectiveAllowed is not null) { return _effectiveAllowed.Contains(ordinal); } + + return ordinal >= _min && ordinal <= _max && !_excludedInRange.Contains(ordinal); + } + /// Draws one ordinal satisfying the whole specification — built directly, never generate-then-retry. internal ulong GenerateOrdinal(Random random) { if (_effectiveAllowed is not null) { diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index 368079cd..00901874 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -45,7 +45,8 @@ matter — and that is the point. - **Collections over any element generator**: `Any.ListOf(item)`, `ArrayOf`, `SequenceOf`, `SetOf` and `DictionaryOf`, constrained with `WithCount`/`NonEmpty`/`Distinct`/`Containing`. A distinct collection asked for more - elements than its element domain can produce fails fast, just like any other + distinct elements than its effective domain — the element generator plus any values + pinned outside it with `Containing` — can produce fails fast, just like any other conflict; `Any.PairOf`/`TripleOf` pair generators into value tuples. - **Optional values**: `.OrNull()` turns any generator into one that is `null` about half the time and otherwise a constrained value — the dummy for an optional field, diff --git a/Dummies/WideIntervalSpec.cs b/Dummies/WideIntervalSpec.cs index 3ee476a3..b297ca16 100644 --- a/Dummies/WideIntervalSpec.cs +++ b/Dummies/WideIntervalSpec.cs @@ -121,6 +121,33 @@ internal WideIntervalSpec WithExcluded(UInt128[] ordinals, string applying) { return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded), applying); } + /// + /// The number of distinct values the specification can produce, or null when the interval is full-width + /// or wider than (a range too vast to ever conflict with a collection count). + /// Feeds , so a distinct collection over a narrow 128-bit range or allow-list + /// can fail eagerly. + /// + internal long? Cardinality { + get { + if (_effectiveAllowed is not null) { return _effectiveAllowed.Count; } + if (IsFullWidth()) { return null; } + + UInt128 count = _max - _min + 1 - (UInt128)_excludedInRange.Count; + + return count <= (UInt128)long.MaxValue ? (long)count : null; + } + } + + /// + /// Whether is a value the specification could produce — the exact domain + /// draws from. Feeds . + /// + internal bool Contains(UInt128 ordinal) { + if (_effectiveAllowed is not null) { return _effectiveAllowed.Contains(ordinal); } + + return ordinal >= _min && ordinal <= _max && !_excludedInRange.Contains(ordinal); + } + /// Draws one ordinal satisfying the whole specification — built directly, never generate-then-retry. internal UInt128 GenerateOrdinal(Random random) { if (_effectiveAllowed is not null) { diff --git a/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md b/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md index ab00ea09..1bee6278 100644 --- a/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md +++ b/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md @@ -2,8 +2,8 @@ 🌍 🇬🇧 [English](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) · 🇫🇷 Français (ce fichier) -**Statut :** Proposé -**Date :** 2026-07-18 +**Statut :** Accepté +**Date :** 2026-07-19 **Décideurs :** Reefact ## Contexte @@ -16,19 +16,29 @@ jamais générée puis filtrée, et jamais derrière une boucle de réessai. L'incrément « collections » ajoute les collections distinctes : `SetOf`, `ListOf(...).Distinct()` et consorts, ainsi que les clés d'un dictionnaire. Une -collection distincte de *N* éléments n'est satisfaisable que si son générateur -d'éléments peut produire au moins *N* valeurs distinctes, ce qui est une propriété -du domaine de ce générateur. +collection distincte de *N* éléments n'est satisfaisable que si *N* valeurs +distinctes peuvent être assemblées depuis son **domaine effectif** : le domaine +propre du générateur d'éléments, élargi par les valeurs fixées avec `Containing(...)` +qui en sortent — chacune est une valeur que le générateur lui-même ne pourrait +jamais tirer — et par les tirages opaques de `ContainingAny(...)`. La cardinalité du +seul générateur d'éléments ne borne donc que les éléments qui doivent venir *de +lui*, non la demande entière. Les générateurs d'éléments se répartissent en deux groupes. Certains tirent d'un -domaine petit et dénombrable : un booléen a deux valeurs, une énumération a ses -membres déclarés, un intervalle entier étroit ou un pool de caractères restreint a -une taille fixe. D'autres tirent d'un domaine effectivement non borné ou -simplement inconnaissable pour la collection : entiers non contraints, chaînes et -identifiants, et — de façon décisive — toute implémentation étrangère de -`IAny` ou tout générateur dérivé (`As`, `Combine`), qui ne porte aucune -information de domaine. `IAny` est une interface publique : la bibliothèque ne -peut donc pas supposer que tout générateur sache rapporter sa cardinalité. +domaine que la bibliothèque sait compter **à bas coût** : les deux valeurs d'un +booléen, les membres déclarés d'une énumération, un intervalle entier ou temporel +étroit, un pool de caractères restreint, une liste blanche explicite (`OneOf`), ou +un scalaire épinglé sur une seule valeur (`Zero`, `Between(x, x)`). D'autres tirent +d'un domaine effectivement non borné, ou dénombrable seulement en principe à un +coût que la bibliothèque refuse de payer : entiers non contraints, chaînes et +identifiants, une **plage** flottante (finie en valeurs représentables, mais les +compter relève d'une arithmétique de bits spécifique au type, disproportionnée pour +l'usage « dummy » — un générateur décimal ou flottant n'est donc contrôlé que via +une liste blanche ou un pin, jamais une plage plus large), et — de façon décisive — +toute implémentation étrangère de `IAny` ou tout générateur dérivé (`As`, +`Combine`), qui ne porte aucune information de domaine. `IAny` est une interface +publique : la bibliothèque ne peut donc pas supposer que tout générateur sache +rapporter sa cardinalité. Un comparateur d'égalité personnalisé ne peut que fusionner des valeurs distinctes en un nombre moindre de classes d'équivalence ; il ne peut jamais en créer de @@ -57,11 +67,17 @@ petit. épuisement, il rapporte le manque réel via une `AnyGenerationException` nommant la graine — le canal d'échec qu'utilise déjà un rejet de fabrique — plutôt que de boucler. -* **La borne annoncée reste correcte sous un comparateur.** Puisqu'un comparateur - ne fait que fusionner des valeurs, la cardinalité annoncée reste une borne - *supérieure* valide : le contrôle anticipé ne rejette donc jamais une demande qui - était en réalité satisfaisable ; un comparateur qui réduit le domaine sous le - nombre demandé est rattrapé par le tirage borné. +* **Le contrôle anticipé ne compte que ce que le générateur doit fournir, et reste + correct sous un comparateur.** Il compare la cardinalité du générateur d'éléments + au nombre demandé *diminué* des valeurs fixées hors de son domaine et de chaque + tirage opaque de `ContainingAny(...)` — ainsi une valeur fixe que le générateur ne + peut produire élargit la demande au lieu d'entrer en conflit avec elle, et un + recouvrement improuvable est renvoyé au tirage borné plutôt que de devenir un faux + conflit. Puisqu'un comparateur ne fait que fusionner des valeurs, la cardinalité + annoncée reste une borne *supérieure* valide sur la contribution propre du + générateur : le contrôle ne rejette donc jamais une demande qui était en réalité + satisfaisable ; un comparateur qui réduit le domaine effectif sous le nombre + demandé est rattrapé par le tirage borné. * **Un seul principe, appliqué là où son information existe.** Répartir l'échec entre la déclaration (quand le domaine est dénombrable) et la génération (quand il ne l'est pas) n'est pas un affaiblissement du principe de conflit anticipé @@ -140,5 +156,6 @@ principe de travail borné de la bibliothèque. ## Références * ADR-0011 — Héberger Dummies comme un paquet autonome dans ce dépôt. -* Le moteur de collection distincte et la capacité de cardinalité, dans le projet - `Dummies` (`CollectionState`, `ICardinalityHint`). +* Le moteur de collection distincte et sa capacité unifiée de cardinalité et + d'appartenance (une seule interface, pour qu'un générateur fini ne puisse pas sortir + du périmètre anticipé), dans le projet `Dummies` (`CollectionState`, `ICardinalityHint`). diff --git a/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md b/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md index 6f3b88e5..f28c63ea 100644 --- a/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md +++ b/doc/handwritten/for-maintainers/adr/0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md @@ -2,8 +2,8 @@ 🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md) -**Status:** Proposed -**Date:** 2026-07-18 +**Status:** Accepted +**Date:** 2026-07-19 **Decision Makers:** Reefact ## Context @@ -16,14 +16,23 @@ never behind a retry loop. The collection increment adds distinct collections: `SetOf`, `ListOf(...).Distinct()` and the like, and a dictionary's keys. A distinct collection of *N* elements is -satisfiable only if its element generator can yield at least *N* distinct values, -which is a property of that generator's domain. - -Element generators fall into two groups. Some draw from a small, countable -domain: a boolean has two values, an enum has its declared members, a narrow -integer range or a restricted character pool has a fixed size. Others draw from a -domain that is effectively unbounded or simply unknowable to the collection: -unconstrained integers, strings and identifiers, and — decisively — any foreign +satisfiable only if *N* distinct values can be assembled from its **effective +domain**: the element generator's own domain, widened by any values pinned with +`Containing(...)` that fall outside it — each such value is one the generator itself +could never draw — and by the opaque draws of `ContainingAny(...)`. The element +generator's cardinality alone therefore bounds only the elements that must come +*from* it, not the whole request. + +Element generators fall into two groups. Some draw from a domain the library can +count **cheaply**: a boolean's two values, an enum's declared members, a narrow +integer or time range, a restricted character pool, an explicit allow-list +(`OneOf`), or a scalar pinned to a single value (`Zero`, `Between(x, x)`). Others +draw from a domain that is effectively unbounded, or countable only in principle +at a cost the library declines to pay: unconstrained integers, strings and +identifiers, a floating-point **range** (finite in representable values, but +counting them is type-specific bit-arithmetic disproportionate to the dummy use +case — so a decimal or floating-point generator is gated only through an +allow-list or a pin, never a wider range), and — decisively — any foreign `IAny` implementation or any derived generator (`As`, `Combine`), which carries no domain information at all. `IAny` is a public interface, so the library cannot assume every generator can report its cardinality. @@ -51,11 +60,16 @@ replayable seed, if the element domain proves too small. bounded honours the no-retry-loop principle; on exhaustion it reports the real shortfall as an `AnyGenerationException` naming the seed — the same failure channel a factory rejection already uses — rather than looping. -* **The advertised bound stays sound under a comparer.** Since a comparer only - merges values, the advertised cardinality remains a valid *upper* bound, so the - eager check never rejects a request that was actually satisfiable; a comparer - that collapses the domain below the requested count is caught by the bounded - draw instead. +* **The eager check counts only what the generator must supply, and stays sound + under a comparer.** It compares the element generator's cardinality against the + count *reduced by* the values pinned outside its domain and by each opaque + `ContainingAny(...)` draw — so a fixed value the generator cannot produce widens + the request instead of conflicting with it, and an unprovable overlap defers to + the bounded draw rather than becoming a false conflict. Because a comparer only + merges values, the advertised cardinality remains a valid *upper* bound on the + generator's own contribution, so the check never rejects a request that was + actually satisfiable; a comparer that collapses the effective domain below the + requested count is caught by the bounded draw instead. * **One principle, applied where its information exists.** Splitting the failure between declaration time (when the domain is countable) and generation time (when it is not) is not a dilution of the eager-conflict principle but its @@ -129,5 +143,6 @@ bounded-work principle. ## References * ADR-0011 — Host Dummies as a standalone package in this repository. -* The distinct-collection engine and the cardinality capability, in the `Dummies` - project (`CollectionState`, `ICardinalityHint`). +* The distinct-collection engine and its unified cardinality-and-membership capability + (one interface, so a finite generator cannot drift out of the eager perimeter), in the + `Dummies` project (`CollectionState`, `ICardinalityHint`). diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 1ee0d4eb..720f2227 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -186,7 +186,7 @@ Optional supporting material: | [ADR-0010](0010-treat-gendocs-error-catalog-as-a-versioned-contract.md) | Treat GenDoc's error catalog as a versioned contract | Accepted | | [ADR-0011](0011-host-dummies-as-a-standalone-package.md) | Host Dummies as a standalone package in this repository | Proposed | | [ADR-0012](0012-fix-the-binder-options-before-binding-begins.md) | Fix the binder options before binding begins | Accepted | -| [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) | Gate distinct collections by cardinality, otherwise by a bounded draw | Proposed | +| [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) | Gate distinct collections by cardinality, otherwise by a bounded draw | Accepted | | [ADR-0014](0014-bind-a-required-list-by-presence-not-cardinality.md) | Bind a required list by presence, not cardinality | Accepted | | [ADR-0015](0015-cap-any-combine-at-arity-eight.md) | Cap Any.Combine at arity eight | Proposed | | [ADR-0016](0016-make-the-binders-structural-error-codes-configurable.md) | Make the binder's structural error codes configurable | Superseded |