From 9ab8e17f2dbc3afb016f8afa223161e331878f80 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 07:03:06 +0000 Subject: [PATCH] feat(dummies): add MultipleOf, WithScale and WithGranularity constraints Constrain scalars to a regular grid, drawn constructively in one pass: MultipleOf on the integers, WithScale on decimal (a 10^-n value lattice), and WithGranularity on the temporals. The grid composes with the existing bounds, exclusions and allow-list, feeds the cardinality hint, and conflicts eagerly when a range holds no grid point; it is declared once and withheld from the binary floating-point types (no exact base-ten grid). This replaces the As(x => x * k) workaround that distorted the declared range and dropped the value out of the constraint algebra, and removes the tick-precision serialization surprise for temporal dummies. Implemented as a shared "step" dimension in the ordinal, wide (128-bit) and decimal interval engines. Splits the SurfaceParityTests algebra families accordingly, updates the netstandard2.0 and net8.0 public API, documents the constraints in the Dummies readme, and proposes ADR-0035. Refs: #226 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JpQ9768mubATEjedfdAZus --- .../AnyLatticeConstraintTests.cs | 229 ++++++++++++++++++ Dummies.UnitTests/SurfaceParityTests.cs | 86 +++++-- Dummies/AnyByte.cs | 15 ++ Dummies/AnyDateTime.cs | 18 ++ Dummies/AnyDateTimeOffset.cs | 18 ++ Dummies/AnyDecimal.cs | 16 ++ Dummies/AnyInt128.cs | 15 ++ Dummies/AnyInt16.cs | 15 ++ Dummies/AnyInt32.cs | 15 ++ Dummies/AnyInt64.cs | 15 ++ Dummies/AnySByte.cs | 15 ++ Dummies/AnyTimeOnly.cs | 18 ++ Dummies/AnyTimeSpan.cs | 17 ++ Dummies/AnyUInt128.cs | 15 ++ Dummies/AnyUInt16.cs | 15 ++ Dummies/AnyUInt32.cs | 15 ++ Dummies/AnyUInt64.cs | 15 ++ Dummies/DecimalIntervalSpec.cs | 167 +++++++++++-- Dummies/OrdinalIntervalSpec.cs | 141 +++++++++-- .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 15 ++ .../netstandard2.0/PublicAPI.Unshipped.txt | 12 + Dummies/README.nuget.md | 10 + Dummies/WideIntervalSpec.cs | 112 ++++++++- ...tice-constrained-scalars-on-the-grid.fr.md | 84 +++++++ ...lattice-constrained-scalars-on-the-grid.md | 84 +++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 26 files changed, 1111 insertions(+), 67 deletions(-) create mode 100644 Dummies.UnitTests/AnyLatticeConstraintTests.cs create mode 100644 doc/handwritten/for-maintainers/adr/0036-draw-lattice-constrained-scalars-on-the-grid.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0036-draw-lattice-constrained-scalars-on-the-grid.md diff --git a/Dummies.UnitTests/AnyLatticeConstraintTests.cs b/Dummies.UnitTests/AnyLatticeConstraintTests.cs new file mode 100644 index 00000000..51edb8cc --- /dev/null +++ b/Dummies.UnitTests/AnyLatticeConstraintTests.cs @@ -0,0 +1,229 @@ +#region Usings declarations + +using NFluent; + +#endregion + +namespace Dummies.UnitTests; + +/// +/// Behaviour of the lattice constraints — MultipleOf on the integers, WithScale on +/// , and WithGranularity on the temporals: values are built directly on the grid in +/// one draw, the grid composes with bounds/exclusions/allow-lists, and an empty grid conflicts eagerly. +/// +public sealed class AnyLatticeConstraintTests { + + private const int SampleCount = 200; + + #region MultipleOf + + [Fact(DisplayName = "MultipleOf: every drawn value is a multiple of the step.")] + public void MultipleOfAlwaysDivisible() { + for (int i = 0; i < SampleCount; i++) { + Check.That(Any.Int32().MultipleOf(100).Generate() % 100).IsEqualTo(0); + Check.That(Any.Int64().Positive().MultipleOf(12L).Generate() % 12L).IsEqualTo(0L); + Check.That(Any.Byte().MultipleOf(5).Generate() % 5).IsEqualTo(0); + } + } + + [Fact(DisplayName = "MultipleOf: draws on the grid within the declared range and reaches both ends.")] + public void MultipleOfHonoursRangeAndReachesBounds() { + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { + int value = Any.Int32().Between(0, 1000).MultipleOf(100).Generate(); + Check.That(value % 100).IsEqualTo(0); + Check.That(value >= 0 && value <= 1000).IsTrue(); + seen.Add(value); + } + + Check.That(seen.Contains(0)).IsTrue(); + Check.That(seen.Contains(1000)).IsTrue(); + } + + [Fact(DisplayName = "MultipleOf: negative multiples are drawn on the grid too.")] + public void MultipleOfHandlesNegativeGrid() { + for (int i = 0; i < SampleCount; i++) { + int value = Any.Int32().Between(-100, -1).MultipleOf(10).Generate(); + Check.That(value % 10).IsEqualTo(0); + Check.That(value >= -100 && value <= -10).IsTrue(); + } + } + + [Fact(DisplayName = "MultipleOf: composes with Except, never yielding an excluded grid point.")] + public void MultipleOfComposesWithExcept() { + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { + int value = Any.Int32().Between(0, 30).MultipleOf(10).Except(10, 20).Generate(); + Check.That(value % 10).IsEqualTo(0); + Check.That(value != 10 && value != 20).IsTrue(); + seen.Add(value); + } + + Check.That(seen.SetEquals([0, 30])).IsTrue(); + } + + [Fact(DisplayName = "MultipleOf: filters a OneOf allow-list to its members on the grid.")] + public void MultipleOfFiltersAllowList() { + for (int i = 0; i < SampleCount; i++) { + int value = Any.Int32().OneOf(5, 10, 15, 20).MultipleOf(10).Generate(); + Check.That(value == 10 || value == 20).IsTrue(); + } + } + + [Fact(DisplayName = "MultipleOf: one is a no-op; zero and negatives are rejected.")] + public void MultipleOfArguments() { + for (int i = 0; i < SampleCount; i++) { Any.Int32().MultipleOf(1).Generate(); } // no-op: any value + + Check.ThatCode(() => Any.Int32().MultipleOf(0)).Throws(); + Check.ThatCode(() => Any.Int32().MultipleOf(-5)).Throws(); + Check.ThatCode(() => Any.Byte().MultipleOf(0)).Throws(); + } + + [Fact(DisplayName = "MultipleOf: an empty grid inside the range conflicts eagerly, naming the step.")] + public void MultipleOfEmptyGridConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.Int32().Between(1, 9).MultipleOf(10)); + Check.That(conflict.Message).Contains("MultipleOf(10)"); + + // The same emptiness, whichever order the two constraints arrive in. + Check.ThatCode(() => Any.Int32().MultipleOf(10).Between(1, 9)).Throws(); + } + + [Fact(DisplayName = "MultipleOf: a second, different step is rejected as already declared.")] + public void MultipleOfDeclaredOnce() { + Check.ThatCode(() => Any.Int32().MultipleOf(4).MultipleOf(6)).Throws(); + // The same step twice is idempotent, not a conflict. + Check.That(Any.Int32().Between(0, 100).MultipleOf(10).MultipleOf(10).Generate() % 10).IsEqualTo(0); + } + + [Fact(DisplayName = "MultipleOf: a distinct collection sees the grid cardinality.")] + public void MultipleOfFeedsCardinality() { + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { seen.Add(Any.Int32().Between(0, 20).MultipleOf(10).Generate()); } + + // Exactly the three grid points are reachable — the cardinality hint a distinct collection relies on. + Check.That(seen.SetEquals([0, 10, 20])).IsTrue(); + } + + #endregion + + #region WithScale + + [Fact(DisplayName = "WithScale: every drawn value lies on the 10^-scale grid.")] + public void WithScaleStaysOnGrid() { + for (int i = 0; i < SampleCount; i++) { + decimal amount = Any.Decimal().Between(0m, 1000m).WithScale(2).Generate(); + Check.That(amount).IsEqualTo(Math.Round(amount, 2, MidpointRounding.ToEven)); + Check.That(amount >= 0m && amount <= 1000m).IsTrue(); + + decimal whole = Any.Decimal().WithScale(0).Generate(); + Check.That(whole).IsEqualTo(Math.Round(whole, 0, MidpointRounding.ToEven)); + } + } + + [Fact(DisplayName = "WithScale: reaches both ends of a narrow grid.")] + public void WithScaleReachesBounds() { + HashSet seen = new(); + for (int i = 0; i < SampleCount; i++) { + decimal value = Any.Decimal().Between(0m, 1m).WithScale(1).Generate(); + Check.That(value).IsEqualTo(Math.Round(value, 1, MidpointRounding.ToEven)); + seen.Add(value); + } + + Check.That(seen.Contains(0m)).IsTrue(); + Check.That(seen.Contains(1m)).IsTrue(); + } + + [Fact(DisplayName = "WithScale: composes with Except on the grid.")] + public void WithScaleComposesWithExcept() { + for (int i = 0; i < SampleCount; i++) { + decimal value = Any.Decimal().Between(0m, 1m).WithScale(1).Except(0.5m).Generate(); + Check.That(value).IsEqualTo(Math.Round(value, 1, MidpointRounding.ToEven)); + Check.That(value).IsNotEqualTo(0.5m); + } + } + + [Fact(DisplayName = "WithScale: a scale outside [0, 28] is rejected.")] + public void WithScaleArguments() { + Check.ThatCode(() => Any.Decimal().WithScale(-1)).Throws(); + Check.ThatCode(() => Any.Decimal().WithScale(29)).Throws(); + Any.Decimal().WithScale(0).Generate(); + Any.Decimal().WithScale(28).Generate(); + } + + [Fact(DisplayName = "WithScale: a range containing no grid point conflicts eagerly.")] + public void WithScaleEmptyGridConflicts() { + ConflictingAnyConstraintException conflict = Assert.Throws( + () => Any.Decimal().Between(0.001m, 0.009m).WithScale(2)); + Check.That(conflict.Message).Contains("WithScale(2)"); + } + + #endregion + + #region WithGranularity + + [Fact(DisplayName = "WithGranularity: every drawn instant/duration lands on the grid.")] + public void WithGranularityStaysOnGrid() { + long quarterHour = TimeSpan.FromMinutes(15).Ticks; + long oneSecond = TimeSpan.FromSeconds(1).Ticks; + for (int i = 0; i < SampleCount; i++) { + Check.That(Any.DateTime().WithGranularity(TimeSpan.FromMinutes(15)).Generate().Ticks % quarterHour).IsEqualTo(0L); + Check.That(Any.TimeSpan().WithGranularity(TimeSpan.FromSeconds(1)).Generate().Ticks % oneSecond).IsEqualTo(0L); + Check.That(Any.DateTimeOffset().WithGranularity(TimeSpan.FromSeconds(1)).Generate().UtcTicks % oneSecond).IsEqualTo(0L); + } + } + + [Fact(DisplayName = "WithGranularity: composes with a range and stays aligned within it.")] + public void WithGranularityHonoursRange() { + DateTime start = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + DateTime end = new(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc); + long day = TimeSpan.FromDays(1).Ticks; + for (int i = 0; i < SampleCount; i++) { + DateTime value = Any.DateTime().Between(start, end).WithGranularity(TimeSpan.FromDays(1)).Generate(); + Check.That(value.Ticks % day).IsEqualTo(0L); + Check.That(value >= start && value <= end).IsTrue(); + } + } + + [Fact(DisplayName = "WithGranularity: a non-positive granularity is rejected.")] + public void WithGranularityArguments() { + Check.ThatCode(() => Any.DateTime().WithGranularity(TimeSpan.Zero)).Throws(); + Check.ThatCode(() => Any.TimeSpan().WithGranularity(TimeSpan.FromTicks(-1))).Throws(); + } + + [Fact(DisplayName = "WithGranularity: a window with no aligned instant conflicts eagerly.")] + public void WithGranularityEmptyGridConflicts() { + DateTime start = new(2026, 1, 1, 0, 0, 1, DateTimeKind.Utc); + DateTime end = new(2026, 1, 1, 0, 0, 2, DateTimeKind.Utc); + + Check.ThatCode(() => Any.DateTime().Between(start, end).WithGranularity(TimeSpan.FromDays(1))) + .Throws(); + } + + #endregion + +#if NET8_0_OR_GREATER + #region Modern types (net8.0) + + [Fact(DisplayName = "MultipleOf: the 128-bit integers draw on the grid too.")] + public void MultipleOfWideIntegers() { + for (int i = 0; i < SampleCount; i++) { + Check.That(Any.Int128().Positive().MultipleOf((Int128)1000).Generate() % 1000 == (Int128)0).IsTrue(); + Check.That(Any.UInt128().MultipleOf((UInt128)7).Generate() % 7 == (UInt128)0).IsTrue(); + } + + Check.ThatCode(() => Any.Int128().Between((Int128)1, (Int128)9).MultipleOf((Int128)10)).Throws(); + } + + [Fact(DisplayName = "WithGranularity: TimeOnly aligns to the grid.")] + public void WithGranularityTimeOnly() { + long oneSecond = TimeSpan.FromSeconds(1).Ticks; + for (int i = 0; i < SampleCount; i++) { + Check.That(Any.TimeOnly().WithGranularity(TimeSpan.FromSeconds(1)).Generate().Ticks % oneSecond).IsEqualTo(0L); + } + } + + #endregion +#endif + +} diff --git a/Dummies.UnitTests/SurfaceParityTests.cs b/Dummies.UnitTests/SurfaceParityTests.cs index ee899113..a8b16bf8 100644 --- a/Dummies.UnitTests/SurfaceParityTests.cs +++ b/Dummies.UnitTests/SurfaceParityTests.cs @@ -86,20 +86,46 @@ private static string Signature(MethodInfo method) { #region Algebra parity: per-family constraint sets // The constraint vocabulary each family declares, encoded once as data. This table is the specification; the - // test compares it against what each builder actually exposes through reflection. - private static readonly string[] SignedNumericAlgebra = [ + // test compares it against what each builder actually exposes through reflection. Ordering here is irrelevant — + // the test compares sets. The lattice constraint splits what was once one signed-numeric family: only the + // integers carry MultipleOf, only Decimal carries WithScale, only the temporals carry WithGranularity. + + // Signed integers: the bound/sign vocabulary plus the integer lattice MultipleOf. + private static readonly string[] SignedIntegerAlgebra = [ "Positive", "Negative", "Zero", "NonZero", "GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo", - "Between", "OneOf", "Except", "DifferentFrom" + "Between", "MultipleOf", "OneOf", "Except", "DifferentFrom" ]; - // Unsigned integers drop Positive/Negative (meaningless there — NonZero carries the intent). - private static readonly string[] UnsignedNumericAlgebra = [ + // Unsigned integers drop Positive/Negative (meaningless there — NonZero carries the intent); they keep MultipleOf. + private static readonly string[] UnsignedIntegerAlgebra = [ "Zero", "NonZero", "GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo", + "Between", "MultipleOf", "OneOf", "Except", "DifferentFrom" + ]; + + // Binary floating-point carries the full signed vocabulary but no lattice: a grid of 10^-n over binary floats is a + // footgun (0.1 is not representable), so MultipleOf/WithScale are deliberately withheld. + private static readonly string[] FloatingPointAlgebra = [ + "Positive", "Negative", "Zero", "NonZero", + "GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo", "Between", "OneOf", "Except", "DifferentFrom" ]; + // Decimal is the signed vocabulary plus the decimal scale lattice WithScale. + private static readonly string[] DecimalAlgebra = [ + "Positive", "Negative", "Zero", "NonZero", + "GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo", + "Between", "OneOf", "Except", "DifferentFrom", "WithScale" + ]; + + // TimeSpan is a signed magnitude with a temporal granularity lattice WithGranularity. + private static readonly string[] TimeSpanAlgebra = [ + "Positive", "Negative", "Zero", "NonZero", + "GreaterThan", "GreaterThanOrEqualTo", "LessThan", "LessThanOrEqualTo", + "Between", "OneOf", "Except", "DifferentFrom", "WithGranularity" + ]; + // Instant-like builders rename the bound family to domain vocabulary, with identical inclusive/exclusive // semantics, and carry no Positive/Negative/Zero (an instant has no sign). private static readonly string[] InstantAlgebra = [ @@ -107,24 +133,32 @@ private static string Signature(MethodInfo method) { "Between", "OneOf", "Except", "DifferentFrom" ]; + // Instants with sub-day tick precision also carry the temporal granularity lattice WithGranularity (DateOnly, + // already day-resolution, keeps the plain InstantAlgebra). + private static readonly string[] InstantWithGranularityAlgebra = [ + "After", "AfterOrEqualTo", "Before", "BeforeOrEqualTo", + "Between", "OneOf", "Except", "DifferentFrom", "WithGranularity" + ]; + public static IEnumerable Builders() { - // Signed integers, the continuous/decimal builders, and TimeSpan (a signed magnitude) share the full algebra. - yield return [typeof(AnyInt32), SignedNumericAlgebra]; - yield return [typeof(AnySByte), SignedNumericAlgebra]; - yield return [typeof(AnyInt16), SignedNumericAlgebra]; - yield return [typeof(AnyInt64), SignedNumericAlgebra]; - yield return [typeof(AnyDouble), SignedNumericAlgebra]; - yield return [typeof(AnySingle), SignedNumericAlgebra]; - yield return [typeof(AnyDecimal), SignedNumericAlgebra]; - yield return [typeof(AnyTimeSpan), SignedNumericAlgebra]; - - yield return [typeof(AnyByte), UnsignedNumericAlgebra]; - yield return [typeof(AnyUInt16), UnsignedNumericAlgebra]; - yield return [typeof(AnyUInt32), UnsignedNumericAlgebra]; - yield return [typeof(AnyUInt64), UnsignedNumericAlgebra]; - - yield return [typeof(AnyDateTime), InstantAlgebra]; - yield return [typeof(AnyDateTimeOffset), InstantAlgebra]; + // Signed integers carry MultipleOf; the binary floats do not; Decimal carries WithScale; TimeSpan (a signed + // magnitude) carries WithGranularity — the lattice constraint is what forks the former shared signed family. + yield return [typeof(AnyInt32), SignedIntegerAlgebra]; + yield return [typeof(AnySByte), SignedIntegerAlgebra]; + yield return [typeof(AnyInt16), SignedIntegerAlgebra]; + yield return [typeof(AnyInt64), SignedIntegerAlgebra]; + yield return [typeof(AnyDouble), FloatingPointAlgebra]; + yield return [typeof(AnySingle), FloatingPointAlgebra]; + yield return [typeof(AnyDecimal), DecimalAlgebra]; + yield return [typeof(AnyTimeSpan), TimeSpanAlgebra]; + + yield return [typeof(AnyByte), UnsignedIntegerAlgebra]; + yield return [typeof(AnyUInt16), UnsignedIntegerAlgebra]; + yield return [typeof(AnyUInt32), UnsignedIntegerAlgebra]; + yield return [typeof(AnyUInt64), UnsignedIntegerAlgebra]; + + yield return [typeof(AnyDateTime), InstantWithGranularityAlgebra]; + yield return [typeof(AnyDateTimeOffset), InstantWithGranularityAlgebra]; // The remaining scalar builders each carry their own deliberate set. yield return [typeof(AnyBoolean), new[] { "True", "False", "DifferentFrom" }]; @@ -142,11 +176,11 @@ public static IEnumerable Builders() { }]; #if NET8_0_OR_GREATER - yield return [typeof(AnyInt128), SignedNumericAlgebra]; - yield return [typeof(AnyHalf), SignedNumericAlgebra]; - yield return [typeof(AnyUInt128), UnsignedNumericAlgebra]; + yield return [typeof(AnyInt128), SignedIntegerAlgebra]; + yield return [typeof(AnyHalf), FloatingPointAlgebra]; + yield return [typeof(AnyUInt128), UnsignedIntegerAlgebra]; yield return [typeof(AnyDateOnly), InstantAlgebra]; - yield return [typeof(AnyTimeOnly), InstantAlgebra]; + yield return [typeof(AnyTimeOnly), InstantWithGranularityAlgebra]; #endif } diff --git a/Dummies/AnyByte.cs b/Dummies/AnyByte.cs index 3fa12ccd..d7429fe3 100644 --- a/Dummies/AnyByte.cs +++ b/Dummies/AnyByte.cs @@ -116,6 +116,21 @@ public AnyByte Between(byte minimum, byte maximum) { return new AnyByte(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint)); } + /// + /// Requires the value to be a multiple of — drawn directly on that lattice, so the + /// declared range keeps its meaning (unlike a post-hoc As(x => x * k) projection). Declared once per + /// generator. + /// + /// The lattice step; must be strictly positive. A value of 1 adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyByte MultipleOf(byte value) { + if (value == 0) { throw new ArgumentOutOfRangeException(nameof(value), value, "The multiple must be strictly positive."); } + + return new AnyByte(_source, _spec.WithStep((ulong)value, Ord(0), $"MultipleOf({V(value)})")); + } + /// Requires the value to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyDateTime.cs b/Dummies/AnyDateTime.cs index 89a5c495..f3e28f9a 100644 --- a/Dummies/AnyDateTime.cs +++ b/Dummies/AnyDateTime.cs @@ -111,6 +111,24 @@ public AnyDateTime Between(DateTime start, DateTime end) { return new AnyDateTime(_source, _spec.WithMinimum(Ord(start), constraint).WithMaximum(Ord(end), constraint), _allowedOriginals); } + /// + /// Requires the instant to fall on a lattice of from + /// — a round instant (a whole second, a quarter-hour, a whole day), built on + /// the grid rather than snapped after the fact, so tick-precision values never surprise a serialization + /// round-trip. Declared once per generator. + /// + /// The lattice step; must be strictly positive. A granularity of one tick adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyDateTime WithGranularity(TimeSpan granularity) { + if (granularity <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException(nameof(granularity), granularity, "The granularity must be strictly positive."); } + + string rendered = granularity.ToString("c", CultureInfo.InvariantCulture); + + return new AnyDateTime(_source, _spec.WithStep((ulong)granularity.Ticks, Ord(DateTime.MinValue), $"WithGranularity({rendered})"), _allowedOriginals); + } + /// Requires the instant to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyDateTimeOffset.cs b/Dummies/AnyDateTimeOffset.cs index 982fd2a3..c7d396c8 100644 --- a/Dummies/AnyDateTimeOffset.cs +++ b/Dummies/AnyDateTimeOffset.cs @@ -113,6 +113,24 @@ public AnyDateTimeOffset Between(DateTimeOffset start, DateTimeOffset end) { return new AnyDateTimeOffset(_source, _spec.WithMinimum(Ord(start), constraint).WithMaximum(Ord(end), constraint), _allowedOriginals); } + /// + /// Requires the instant to fall on a lattice of from + /// — a round instant (a whole second, a quarter-hour, a whole day), + /// built on the grid rather than snapped after the fact, so tick-precision values never surprise a + /// serialization round-trip. Declared once per generator. + /// + /// The lattice step; must be strictly positive. A granularity of one tick adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyDateTimeOffset WithGranularity(TimeSpan granularity) { + if (granularity <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException(nameof(granularity), granularity, "The granularity must be strictly positive."); } + + string rendered = granularity.ToString("c", CultureInfo.InvariantCulture); + + return new AnyDateTimeOffset(_source, _spec.WithStep((ulong)granularity.Ticks, Ord(DateTimeOffset.MinValue), $"WithGranularity({rendered})"), _allowedOriginals); + } + /// /// Requires the instant to be one of the supplied values — returned as given, offset included. Declared once /// per generator. diff --git a/Dummies/AnyDecimal.cs b/Dummies/AnyDecimal.cs index a40fb7c5..587ccb17 100644 --- a/Dummies/AnyDecimal.cs +++ b/Dummies/AnyDecimal.cs @@ -123,6 +123,22 @@ public AnyDecimal Between(decimal minimum, decimal maximum) { return new AnyDecimal(_source, _spec.WithMinimum(minimum, constraint).WithMaximum(maximum, constraint)); } + /// + /// Requires the value to be expressible in decimal places — a multiple of + /// 10^- (a valid amount in cents is WithScale(2)), drawn directly on that grid. + /// A value lattice, not a representation contract: the drawn value lies on the grid but is not padded with + /// trailing zeros. Declared once per generator. + /// + /// The number of decimal places; in the inclusive range [0, 28]. + /// A new generator carrying the added constraint. + /// Thrown when is outside the range [0, 28]. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyDecimal WithScale(int scale) { + if (scale < 0 || scale > 28) { throw new ArgumentOutOfRangeException(nameof(scale), scale, "The scale must be in the inclusive range [0, 28]."); } + + return new AnyDecimal(_source, _spec.WithScale(scale, $"WithScale({scale.ToString(CultureInfo.InvariantCulture)})")); + } + /// Requires the value to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyInt128.cs b/Dummies/AnyInt128.cs index 96eb0de5..a8d581b6 100644 --- a/Dummies/AnyInt128.cs +++ b/Dummies/AnyInt128.cs @@ -132,6 +132,21 @@ public AnyInt128 Between(Int128 minimum, Int128 maximum) { return new AnyInt128(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint)); } + /// + /// Requires the value to be a multiple of — drawn directly on that lattice, so the + /// declared range keeps its meaning (unlike a post-hoc As(x => x * k) projection). Declared once per + /// generator. + /// + /// The lattice step; must be strictly positive. A value of 1 adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt128 MultipleOf(Int128 value) { + if (value <= 0) { throw new ArgumentOutOfRangeException(nameof(value), value, "The multiple must be strictly positive."); } + + return new AnyInt128(_source, _spec.WithStep((UInt128)value, Ord(0), $"MultipleOf({V(value)})")); + } + /// Requires the value to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyInt16.cs b/Dummies/AnyInt16.cs index 11b141b8..bccb502a 100644 --- a/Dummies/AnyInt16.cs +++ b/Dummies/AnyInt16.cs @@ -130,6 +130,21 @@ public AnyInt16 Between(short minimum, short maximum) { return new AnyInt16(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint)); } + /// + /// Requires the value to be a multiple of — drawn directly on that lattice, so the + /// declared range keeps its meaning (unlike a post-hoc As(x => x * k) projection). Declared once per + /// generator. + /// + /// The lattice step; must be strictly positive. A value of 1 adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt16 MultipleOf(short value) { + if (value <= 0) { throw new ArgumentOutOfRangeException(nameof(value), value, "The multiple must be strictly positive."); } + + return new AnyInt16(_source, _spec.WithStep((ulong)value, Ord(0), $"MultipleOf({V(value)})")); + } + /// Requires the value to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyInt32.cs b/Dummies/AnyInt32.cs index 188e1be0..666c597f 100644 --- a/Dummies/AnyInt32.cs +++ b/Dummies/AnyInt32.cs @@ -146,6 +146,21 @@ public AnyInt32 Between(int minimum, int maximum) { return new AnyInt32(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint)); } + /// + /// Requires the value to be a multiple of — drawn directly on that lattice, so the + /// declared range keeps its meaning (unlike a post-hoc As(x => x * k) projection). Declared once per + /// generator. + /// + /// The lattice step; must be strictly positive. A value of 1 adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt32 MultipleOf(int value) { + if (value <= 0) { throw new ArgumentOutOfRangeException(nameof(value), value, "The multiple must be strictly positive."); } + + return new AnyInt32(_source, _spec.WithStep((ulong)value, Ord(0), $"MultipleOf({V(value)})")); + } + /// Requires the value to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyInt64.cs b/Dummies/AnyInt64.cs index 2ad73622..25f94539 100644 --- a/Dummies/AnyInt64.cs +++ b/Dummies/AnyInt64.cs @@ -130,6 +130,21 @@ public AnyInt64 Between(long minimum, long maximum) { return new AnyInt64(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint)); } + /// + /// Requires the value to be a multiple of — drawn directly on that lattice, so the + /// declared range keeps its meaning (unlike a post-hoc As(x => x * k) projection). Declared once per + /// generator. + /// + /// The lattice step; must be strictly positive. A value of 1 adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyInt64 MultipleOf(long value) { + if (value <= 0) { throw new ArgumentOutOfRangeException(nameof(value), value, "The multiple must be strictly positive."); } + + return new AnyInt64(_source, _spec.WithStep((ulong)value, Ord(0), $"MultipleOf({V(value)})")); + } + /// Requires the value to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnySByte.cs b/Dummies/AnySByte.cs index 0ef14ae0..67cadcf6 100644 --- a/Dummies/AnySByte.cs +++ b/Dummies/AnySByte.cs @@ -130,6 +130,21 @@ public AnySByte Between(sbyte minimum, sbyte maximum) { return new AnySByte(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint)); } + /// + /// Requires the value to be a multiple of — drawn directly on that lattice, so the + /// declared range keeps its meaning (unlike a post-hoc As(x => x * k) projection). Declared once per + /// generator. + /// + /// The lattice step; must be strictly positive. A value of 1 adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnySByte MultipleOf(sbyte value) { + if (value <= 0) { throw new ArgumentOutOfRangeException(nameof(value), value, "The multiple must be strictly positive."); } + + return new AnySByte(_source, _spec.WithStep((ulong)value, Ord(0), $"MultipleOf({V(value)})")); + } + /// Requires the value to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyTimeOnly.cs b/Dummies/AnyTimeOnly.cs index c7f5d6b6..eb669b7e 100644 --- a/Dummies/AnyTimeOnly.cs +++ b/Dummies/AnyTimeOnly.cs @@ -106,6 +106,24 @@ public AnyTimeOnly Between(TimeOnly start, TimeOnly end) { return new AnyTimeOnly(_source, _spec.WithMinimum(Ord(start), constraint).WithMaximum(Ord(end), constraint)); } + /// + /// Requires the time of day to fall on a lattice of from + /// — a round time of day (a whole second, a quarter-hour), built on the grid + /// rather than snapped after the fact, so tick-precision values never surprise a serialization round-trip. + /// Declared once per generator. + /// + /// The lattice step; must be strictly positive. A granularity of one tick adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyTimeOnly WithGranularity(TimeSpan granularity) { + if (granularity <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException(nameof(granularity), granularity, "The granularity must be strictly positive."); } + + string rendered = granularity.ToString("c", CultureInfo.InvariantCulture); + + return new AnyTimeOnly(_source, _spec.WithStep((ulong)granularity.Ticks, Ord(TimeOnly.MinValue), $"WithGranularity({rendered})")); + } + /// Requires the time of day to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyTimeSpan.cs b/Dummies/AnyTimeSpan.cs index 69423a79..57af0a48 100644 --- a/Dummies/AnyTimeSpan.cs +++ b/Dummies/AnyTimeSpan.cs @@ -131,6 +131,23 @@ public AnyTimeSpan Between(TimeSpan minimum, TimeSpan maximum) { return new AnyTimeSpan(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint)); } + /// + /// Requires the duration to fall on a lattice of from — + /// a whole number of that granularity, built on the grid rather than snapped after the fact, so tick-precision + /// values never surprise a serialization round-trip. Declared once per generator. + /// + /// The lattice step; must be strictly positive. A granularity of one tick adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyTimeSpan WithGranularity(TimeSpan granularity) { + if (granularity <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException(nameof(granularity), granularity, "The granularity must be strictly positive."); } + + string rendered = granularity.ToString("c", CultureInfo.InvariantCulture); + + return new AnyTimeSpan(_source, _spec.WithStep((ulong)granularity.Ticks, Ord(TimeSpan.Zero), $"WithGranularity({rendered})")); + } + /// Requires the duration to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyUInt128.cs b/Dummies/AnyUInt128.cs index 77be9bb6..dae5e26e 100644 --- a/Dummies/AnyUInt128.cs +++ b/Dummies/AnyUInt128.cs @@ -118,6 +118,21 @@ public AnyUInt128 Between(UInt128 minimum, UInt128 maximum) { return new AnyUInt128(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint)); } + /// + /// Requires the value to be a multiple of — drawn directly on that lattice, so the + /// declared range keeps its meaning (unlike a post-hoc As(x => x * k) projection). Declared once per + /// generator. + /// + /// The lattice step; must be strictly positive. A value of 1 adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyUInt128 MultipleOf(UInt128 value) { + if (value == UInt128.Zero) { throw new ArgumentOutOfRangeException(nameof(value), value, "The multiple must be strictly positive."); } + + return new AnyUInt128(_source, _spec.WithStep(value, Ord(UInt128.Zero), $"MultipleOf({V(value)})")); + } + /// Requires the value to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyUInt16.cs b/Dummies/AnyUInt16.cs index ce2a7b94..2cf5c570 100644 --- a/Dummies/AnyUInt16.cs +++ b/Dummies/AnyUInt16.cs @@ -116,6 +116,21 @@ public AnyUInt16 Between(ushort minimum, ushort maximum) { return new AnyUInt16(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint)); } + /// + /// Requires the value to be a multiple of — drawn directly on that lattice, so the + /// declared range keeps its meaning (unlike a post-hoc As(x => x * k) projection). Declared once per + /// generator. + /// + /// The lattice step; must be strictly positive. A value of 1 adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyUInt16 MultipleOf(ushort value) { + if (value == 0) { throw new ArgumentOutOfRangeException(nameof(value), value, "The multiple must be strictly positive."); } + + return new AnyUInt16(_source, _spec.WithStep((ulong)value, Ord(0), $"MultipleOf({V(value)})")); + } + /// Requires the value to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyUInt32.cs b/Dummies/AnyUInt32.cs index 92f41085..ea1f9a20 100644 --- a/Dummies/AnyUInt32.cs +++ b/Dummies/AnyUInt32.cs @@ -116,6 +116,21 @@ public AnyUInt32 Between(uint minimum, uint maximum) { return new AnyUInt32(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint)); } + /// + /// Requires the value to be a multiple of — drawn directly on that lattice, so the + /// declared range keeps its meaning (unlike a post-hoc As(x => x * k) projection). Declared once per + /// generator. + /// + /// The lattice step; must be strictly positive. A value of 1 adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyUInt32 MultipleOf(uint value) { + if (value == 0) { throw new ArgumentOutOfRangeException(nameof(value), value, "The multiple must be strictly positive."); } + + return new AnyUInt32(_source, _spec.WithStep((ulong)value, Ord(0), $"MultipleOf({V(value)})")); + } + /// Requires the value to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/AnyUInt64.cs b/Dummies/AnyUInt64.cs index 61b24eac..d829ef07 100644 --- a/Dummies/AnyUInt64.cs +++ b/Dummies/AnyUInt64.cs @@ -116,6 +116,21 @@ public AnyUInt64 Between(ulong minimum, ulong maximum) { return new AnyUInt64(_source, _spec.WithMinimum(Ord(minimum), constraint).WithMaximum(Ord(maximum), constraint)); } + /// + /// Requires the value to be a multiple of — drawn directly on that lattice, so the + /// declared range keeps its meaning (unlike a post-hoc As(x => x * k) projection). Declared once per + /// generator. + /// + /// The lattice step; must be strictly positive. A value of 1 adds no constraint. + /// A new generator carrying the added constraint. + /// Thrown when is not strictly positive. + /// Thrown when the constraint contradicts a constraint already declared. + public AnyUInt64 MultipleOf(ulong value) { + if (value == 0) { throw new ArgumentOutOfRangeException(nameof(value), value, "The multiple must be strictly positive."); } + + return new AnyUInt64(_source, _spec.WithStep(value, Ord(0), $"MultipleOf({V(value)})")); + } + /// Requires the value to be one of the supplied values. Declared once per generator. /// The allowed values; duplicates are ignored. /// A new generator carrying the added constraint. diff --git a/Dummies/DecimalIntervalSpec.cs b/Dummies/DecimalIntervalSpec.cs index 9d0c941f..4dbd26f7 100644 --- a/Dummies/DecimalIntervalSpec.cs +++ b/Dummies/DecimalIntervalSpec.cs @@ -4,10 +4,14 @@ namespace Dummies; /// The immutable engine behind — the same algebra as /// in arithmetic. has no /// next-representable-value ladder, so exclusive bounds are expressed as an inclusive bound plus a point -/// exclusion, and a colliding draw is nudged by the smallest decimal increment within a bounded budget. +/// exclusion, and a colliding draw is nudged by the smallest decimal increment within a bounded budget. An +/// optional scale lattice (set by WithScale) restricts the domain to the multiples of +/// 10^-scale — every value expressible in scale decimal places — by snapping the drawn candidate to +/// the grid, still in one constructive draw. /// internal sealed class DecimalIntervalSpec { + private const int NoScale = -1; private const int NudgeBudget = 128; private static readonly decimal SmallestStep = 0.0000000000000000000000000001m; @@ -16,7 +20,15 @@ internal sealed class DecimalIntervalSpec { #region Statics members declarations internal static DecimalIntervalSpec Unconstrained(string typeName, Func render) { - return new DecimalIntervalSpec(typeName, render, decimal.MinValue, null, decimal.MaxValue, null, null, null, []); + return new DecimalIntervalSpec(typeName, render, decimal.MinValue, null, decimal.MaxValue, null, null, null, [], NoScale, null); + } + + /// Ten raised to as an exact ( in [0, 28]). + private static decimal Pow10(int power) { + decimal result = 1m; + for (int i = 0; i < power; i++) { result *= 10m; } + + return result; } #endregion @@ -25,13 +37,20 @@ internal static DecimalIntervalSpec Unconstrained(string typeName, Func? _allowed; private readonly string? _allowedConstraint; + private readonly decimal _ceiledMin; private readonly List? _effectiveAllowed; private readonly IReadOnlyList _excluded; + private readonly int _excludedOnLattice; + private readonly decimal _flooredMax; + private readonly bool _latticeHasPoint; private readonly decimal _max; private readonly string? _maxConstraint; private readonly decimal _min; private readonly string? _minConstraint; private readonly Func _render; + private readonly int _scale; + private readonly string? _scaleConstraint; + private readonly decimal _step; private readonly string _typeName; #endregion @@ -40,7 +59,8 @@ private DecimalIntervalSpec(string typeName, Func render, decimal min, string? minConstraint, decimal max, string? maxConstraint, IReadOnlyList? allowed, string? allowedConstraint, - IReadOnlyList excluded) { + IReadOnlyList excluded, + int scale, string? scaleConstraint) { _typeName = typeName; _render = render; _min = min; @@ -50,8 +70,24 @@ private DecimalIntervalSpec(string typeName, Func render, _allowed = allowed; _allowedConstraint = allowedConstraint; _excluded = excluded; + _scale = scale; + _scaleConstraint = scaleConstraint; + // Lattice-derived state, materialized once — "constrain once, draw many". + if (scale >= 0) { + _step = 1m / Pow10(scale); + _ceiledMin = CeilToGrid(min, scale, _step); + _flooredMax = FloorToGrid(max, scale, _step); + _latticeHasPoint = _ceiledMin <= _flooredMax; + _excludedOnLattice = excluded.Count(value => value >= min && value <= max && IsOnGrid(value, scale)); + } else { + _step = 0m; + _ceiledMin = min; + _flooredMax = max; + _latticeHasPoint = true; + _excludedOnLattice = 0; + } // Materialized once here — "constrain once, draw many": Generate never refilters the allow-list. - _effectiveAllowed = allowed?.Where(value => value >= min && value <= max && !IsExcluded(value)).ToList(); + _effectiveAllowed = allowed?.Where(value => value >= min && value <= max && !IsExcluded(value) && (scale < 0 || IsOnGrid(value, scale))).ToList(); } /// Tightens the lower bound; a looser bound than the current one is a no-op. @@ -64,7 +100,7 @@ internal DecimalIntervalSpec WithMinimum(decimal minimum, string applying) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_maxConstraint} already requires values less than or equal to {_render(_max)}."); } - return Validated(new DecimalIntervalSpec(_typeName, _render, minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _excluded), applying); + return Validated(new DecimalIntervalSpec(_typeName, _render, minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _excluded, _scale, _scaleConstraint), applying); } /// Tightens the upper bound; a looser bound than the current one is a no-op. @@ -77,7 +113,7 @@ internal DecimalIntervalSpec WithMaximum(decimal maximum, string applying) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_minConstraint} already requires values greater than or equal to {_render(_min)}."); } - return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _excluded), applying); + return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _excluded, _scale, _scaleConstraint), applying); } /// Tightens the lower bound to strictly above — the inclusive bound plus a point exclusion. @@ -96,7 +132,7 @@ internal DecimalIntervalSpec WithAllowed(decimal[] values, string applying) { decimal[] distinct = values.Distinct().ToArray(); - return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, _max, _maxConstraint, distinct, applying, _excluded), applying); + return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, _max, _maxConstraint, distinct, applying, _excluded, _scale, _scaleConstraint), applying); } /// Adds values the generator must never produce. @@ -104,19 +140,39 @@ internal DecimalIntervalSpec WithExcluded(decimal[] values, string applying) { List excluded = new(_excluded); excluded.AddRange(values); - return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded), applying); + return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded, _scale, _scaleConstraint), 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 . + /// Restricts the domain to the multiples of 10^-scale — the values expressible in + /// decimal places. A value lattice, not a representation contract: the drawn value lies on the grid, but its + /// rendered form is not padded with trailing zeros. Declared once per generator. + /// + internal DecimalIntervalSpec WithScale(int scale, string applying) { + if (_scale >= 0) { + if (_scale == scale) { return this; } + + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_scaleConstraint} is already defined."); + } + + return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, _excluded, scale, applying), applying); + } + + /// + /// The number of distinct values the specification can produce — the allow-list size when one is set; the number + /// of non-excluded grid points when a scale lattice is set and that count fits a ; 1 + /// for a validated pin; and null otherwise (a wider interval is a countable but + /// astronomically large domain, 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 (_scale >= 0) { + long? points = LatticePointCount(); + + return points is null ? null : Math.Max(0, points.Value - _excludedOnLattice); + } if (_min == _max) { return 1; } return null; @@ -125,10 +181,12 @@ internal long? Cardinality { /// /// 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. + /// one is set, otherwise on the grid (when a scale lattice is set), inside the interval and not excluded. + /// Mirrors 's own domain. /// internal bool Contains(decimal value) { if (_effectiveAllowed is not null) { return _effectiveAllowed.Contains(value); } + if (_scale >= 0 && !IsOnGrid(value, _scale)) { return false; } return value >= _min && value <= _max && !IsExcluded(value); } @@ -160,6 +218,24 @@ internal decimal Generate(RandomSource source) { // doubles to just past decimal.MaxValue, throwing on an unconstrained Any.Decimal().Generate(). decimal candidate = Clamped(_min * (1m - fraction) + _max * fraction); + if (_scale >= 0) { + // Snap the draw onto the grid, then pull it inside the reachable grid window. A snapped point that + // collides with an exclusion is walked one grid step at a time — ascending first, then descending — + // a deterministic, bounded walk, not a retry loop. + decimal snapped = Math.Round(candidate, _scale, MidpointRounding.ToEven); + if (snapped < _ceiledMin) { snapped = _ceiledMin; } else if (snapped > _flooredMax) { snapped = _flooredMax; } + + decimal? free = NudgeOnGrid(snapped, true) ?? NudgeOnGrid(snapped, false); + if (free is null) { + throw new AnyGenerationException( + $"Generation failed: no {_typeName} value near the drawn candidate satisfies the exclusions. {source.ReplayHint(current.Seed)}", + current.Seed, + new InvalidOperationException("The grid nudge could not leave the excluded point within the allowed range.")); + } + + return free.Value; + } + // A draw colliding with an excluded point is walked by the smallest decimal step — deterministic and // bounded, not a retry loop. (At extreme magnitudes the step can vanish in rounding; the budget then // fails the generation loudly instead of looping.) @@ -179,6 +255,58 @@ internal decimal Generate(RandomSource source) { return candidate; } + /// + /// Walks from along the grid — ascending or descending by one step — to the nearest + /// value the exclusions allow, staying within the reachable grid window. Returns null when the walk + /// reaches the window edge before finding one, so the caller can try the opposite direction. + /// + private decimal? NudgeOnGrid(decimal from, bool ascending) { + decimal candidate = from; + int budget = NudgeBudget; + while (IsExcluded(candidate)) { + decimal next = ascending ? candidate + _step : candidate - _step; + if (next < _ceiledMin || next > _flooredMax || budget-- == 0) { return null; } + + candidate = next; + } + + return candidate; + } + + /// The number of grid points in [min, max], or null when that exceeds . + private long? LatticePointCount() { + if (!_latticeHasPoint) { return 0; } + + decimal maxCountable = _step * long.MaxValue; // _step is at most 1, so this never overflows + // The span itself can exceed the decimal range (an unconstrained WithScale spans MinValue..MaxValue). Only a + // straddling range risks that; when either half alone already outruns the countable span there are too many + // points, so short-circuit before forming a difference that would throw. + if (_ceiledMin < 0m && _flooredMax > 0m && (_flooredMax > maxCountable || -_ceiledMin > maxCountable)) { return null; } + + decimal span = _flooredMax - _ceiledMin; + if (span > maxCountable) { return null; } + + return (long)(span / _step) + 1; + } + + private static bool IsOnGrid(decimal value, int scale) { + return Math.Round(value, scale, MidpointRounding.ToEven) == value; + } + + /// The smallest grid point at or above . + private static decimal CeilToGrid(decimal value, int scale, decimal step) { + decimal rounded = Math.Round(value, scale, MidpointRounding.ToEven); + + return rounded >= value ? rounded : rounded + step; + } + + /// The largest grid point at or below . + private static decimal FloorToGrid(decimal value, int scale, decimal step) { + decimal rounded = Math.Round(value, scale, MidpointRounding.ToEven); + + return rounded <= value ? rounded : rounded - step; + } + private decimal Clamped(decimal value) { if (value < _min) { return _min; } if (value > _max) { return _max; } @@ -202,6 +330,13 @@ private DecimalIntervalSpec Validated(DecimalIntervalSpec candidate, string appl private bool IsSatisfiable() { if (_effectiveAllowed is not null) { return _effectiveAllowed.Count > 0; } + if (_scale >= 0) { + if (!_latticeHasPoint) { return false; } + + long? points = LatticePointCount(); + + return points is null || points.Value > _excludedOnLattice; + } if (_min < _max) { return true; } return !IsExcluded(_min); @@ -216,6 +351,10 @@ private string DescribeExhaustion() { return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; } + if (_scale >= 0) { + return $"no {_typeName} value {_scaleConstraint} allows remains between {_render(_min)} and {_render(_max)}"; + } + string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; return $"{pinning} already pins the value to {_render(_min)}, which the exclusions forbid"; diff --git a/Dummies/OrdinalIntervalSpec.cs b/Dummies/OrdinalIntervalSpec.cs index 437d20ed..2b9dad1d 100644 --- a/Dummies/OrdinalIntervalSpec.cs +++ b/Dummies/OrdinalIntervalSpec.cs @@ -24,16 +24,24 @@ internal static long ToInt64(ulong ordinal) { /// /// The shared immutable engine behind every discrete interval-shaped generator (integers, TimeSpan, -/// DateTime, ...): an inclusive interval of ordinals, an optional allow-list (OneOf), and an -/// exclusion list — each bound remembering the constraint that set it, so a conflict message can name both -/// sides. Every mutation returns a new specification and validates satisfiability eagerly: a generator that -/// exists can always generate, in one draw, with no retry. +/// DateTime, ...): an inclusive interval of ordinals, an optional allow-list (OneOf), an +/// exclusion list, and an optional lattice (a step and an anchor, set by MultipleOf / a temporal +/// granularity) restricting the domain to values spaced a fixed distance apart — each bound remembering the +/// constraint that set it, so a conflict message can name both sides. Every mutation returns a new specification +/// and validates satisfiability eagerly: a generator that exists can always generate, in one draw, with no retry. /// /// /// The engine is domain-agnostic: each public generator supplies its type's display name (for "no Int64 value /// satisfies it" messages), a renderer turning an ordinal back into a displayable value, and the ordinal bounds /// of its domain. The conflict logic therefore lives once, and a fix to a message or an edge case reaches every /// discrete type at the same time. +/// +/// The lattice works because the ordinal map is affine: consecutive multiples of a step in value space stay a +/// constant step apart in ordinal space. The valid ordinals are therefore an arithmetic progression through a +/// known lattice ordinal (the anchor — the ordinal of the value 0, itself a multiple of every step), +/// found by striding from the first lattice point at or above the minimum. Sampling stays inside the drawn +/// window, so the wraparound at the ordinal-space edge is never crossed. +/// /// internal sealed class OrdinalIntervalSpec { @@ -41,7 +49,36 @@ internal sealed class OrdinalIntervalSpec { internal static OrdinalIntervalSpec Unconstrained(string typeName, Func render, ulong domainMin, ulong domainMax) { return new OrdinalIntervalSpec(typeName, render, domainMin, domainMax, - domainMin, null, domainMax, null, null, null, []); + domainMin, null, domainMax, null, null, null, [], + 1UL, 0UL, null); + } + + /// Whether sits on the lattice anchored at with the given step. + private static bool IsOnLattice(ulong ordinal, ulong anchor, ulong step) { + ulong delta = ordinal >= anchor ? ordinal - anchor : anchor - ordinal; + + return delta % step == 0UL; + } + + /// + /// The smallest lattice ordinal at or above , staying within [min, max]. Returns + /// false when none exists (the stride steps past , or the domain top overflows). + /// + private static bool TryFirstLatticePoint(ulong min, ulong max, ulong anchor, ulong step, out ulong first) { + if (min >= anchor) { + ulong ahead = (min - anchor) % step; + if (ahead == 0UL) { + first = min; + } else { + first = min + (step - ahead); + if (first < min) { first = 0UL; return false; } // wrapped past the top of the ordinal domain + } + } else { + // The nearest lattice point at or above min is min plus its distance up to the anchor's phase. + first = min + (anchor - min) % step; + } + + return first <= max; } #endregion @@ -50,16 +87,22 @@ internal static OrdinalIntervalSpec Unconstrained(string typeName, Func? _allowed; private readonly string? _allowedConstraint; + private readonly ulong _anchor; private readonly ulong _domainMax; - private readonly List? _effectiveAllowed; - private readonly List _excludedInRange; private readonly ulong _domainMin; + private readonly List? _effectiveAllowed; private readonly IReadOnlyList _excluded; + private readonly List _excludedInRange; + private readonly List _excludedOnLattice; + private readonly ulong _latticeFirst; + private readonly bool _latticeHasPoint; private readonly ulong _max; private readonly string? _maxConstraint; private readonly ulong _min; private readonly string? _minConstraint; private readonly Func _render; + private readonly ulong _step; + private readonly string? _stepConstraint; private readonly string _typeName; #endregion @@ -68,7 +111,8 @@ private OrdinalIntervalSpec(string typeName, Func render, ulong d ulong min, string? minConstraint, ulong max, string? maxConstraint, IReadOnlyList? allowed, string? allowedConstraint, - IReadOnlyList excluded) { + IReadOnlyList excluded, + ulong step, ulong anchor, string? stepConstraint) { _typeName = typeName; _render = render; _domainMin = domainMin; @@ -80,12 +124,25 @@ private OrdinalIntervalSpec(string typeName, Func render, ulong d _allowed = allowed; _allowedConstraint = allowedConstraint; _excluded = excluded; + _step = step; + _anchor = anchor; + _stepConstraint = stepConstraint; // Materialized once here — "constrain once, draw many": GenerateOrdinal never refilters or resorts. _excludedInRange = excluded.Where(value => value >= min && value <= max).Distinct().ToList(); _excludedInRange.Sort(); + // Lattice-derived state, kept alongside so the hot path is a straight index-and-stride. + if (step > 1UL) { + _latticeHasPoint = TryFirstLatticePoint(min, max, anchor, step, out _latticeFirst); + _excludedOnLattice = _excludedInRange.Where(value => IsOnLattice(value, anchor, step)).ToList(); // stays sorted: filtered from a sorted list + } else { + _latticeHasPoint = true; + _latticeFirst = min; + _excludedOnLattice = _excludedInRange; + } + if (allowed is not null) { HashSet forbidden = new(excluded); - _effectiveAllowed = allowed.Where(value => value >= min && value <= max && !forbidden.Contains(value)).ToList(); + _effectiveAllowed = allowed.Where(value => value >= min && value <= max && !forbidden.Contains(value) && (step <= 1UL || IsOnLattice(value, anchor, step))).ToList(); } } @@ -99,7 +156,7 @@ internal OrdinalIntervalSpec WithMinimum(ulong minimum, string applying) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_maxConstraint} already requires values less than or equal to {_render(_max)}."); } - return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _excluded), applying); + return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _excluded, _step, _anchor, _stepConstraint), applying); } /// Tightens the lower bound to strictly above — the exclusive form of . @@ -119,7 +176,7 @@ internal OrdinalIntervalSpec WithMaximum(ulong maximum, string applying) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_minConstraint} already requires values greater than or equal to {_render(_min)}."); } - return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _excluded), applying); + return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _excluded, _step, _anchor, _stepConstraint), applying); } /// Tightens the upper bound to strictly below — the exclusive form of . @@ -135,7 +192,7 @@ internal OrdinalIntervalSpec WithAllowed(ulong[] ordinals, string applying) { ulong[] distinct = ordinals.Distinct().ToArray(); - return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, distinct, applying, _excluded), applying); + return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, distinct, applying, _excluded, _step, _anchor, _stepConstraint), applying); } /// Adds values the generator must never produce. @@ -143,7 +200,24 @@ internal OrdinalIntervalSpec WithExcluded(ulong[] ordinals, string applying) { List excluded = new(_excluded); excluded.AddRange(ordinals); - return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded), applying); + return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded, _step, _anchor, _stepConstraint), applying); + } + + /// + /// Restricts the domain to a lattice: values a multiple of away from + /// — a known lattice ordinal, the ordinal of the value 0. Declared once per + /// generator (a second, different lattice conflicts rather than silently intersecting). + /// + internal OrdinalIntervalSpec WithStep(ulong step, ulong anchor, string applying) { + if (step <= 1UL) { return this; } // every value is a multiple of one: a no-op, not a constraint + + if (_step > 1UL) { + if (_step == step && _anchor == anchor) { return this; } + + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_stepConstraint} is already defined."); + } + + return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, _excluded, step, anchor, applying), applying); } /// @@ -154,6 +228,13 @@ internal OrdinalIntervalSpec WithExcluded(ulong[] ordinals, string applying) { internal long? Cardinality { get { if (_effectiveAllowed is not null) { return _effectiveAllowed.Count; } + if (_step > 1UL) { + if (!_latticeHasPoint) { return 0; } + + ulong onLattice = (_max - _latticeFirst) / _step + 1UL - (ulong)_excludedOnLattice.Count; + + return onLattice <= long.MaxValue ? (long)onLattice : null; + } if (IsFullWidth()) { return null; } ulong count = _max - _min + 1UL - (ulong)_excludedInRange.Count; @@ -164,12 +245,13 @@ 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. + /// draws from: a member of the allow-list when one is set, otherwise on the + /// lattice (when one is set), 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); } + if (_step > 1UL && !IsOnLattice(ordinal, _anchor, _step)) { return false; } return ordinal >= _min && ordinal <= _max && !_excludedInRange.Contains(ordinal); } @@ -180,6 +262,20 @@ internal ulong GenerateOrdinal(Random random) { return _effectiveAllowed[random.Next(_effectiveAllowed.Count)]; } + if (_step > 1UL) { + // The lattice caps the count below 2^64 (a step of two already halves the domain), so the + // full-width special case below never applies here. Draw an index over the surviving lattice + // points, then shift past any excluded lattice point at or below the drawn ordinal. + ulong latticeCount = (_max - _latticeFirst) / _step + 1UL; + ulong validCount = latticeCount - (ulong)_excludedOnLattice.Count; + ulong ordinal = _latticeFirst + (random.NextUInt64() % validCount) * _step; + foreach (ulong value in _excludedOnLattice) { + if (ordinal >= value) { ordinal += _step; } + } + + return ordinal; + } + List excluded = _excludedInRange; if (IsFullWidth()) { // The interval spans the whole ordinal space, so its size does not fit a ulong and the index @@ -191,8 +287,8 @@ internal ulong GenerateOrdinal(Random random) { return candidate; } - ulong validCount = _max - _min + 1 - (ulong)excluded.Count; - ulong candidateOrdinal = _min + random.NextUInt64() % validCount; + ulong validCountInRange = _max - _min + 1 - (ulong)excluded.Count; + ulong candidateOrdinal = _min + random.NextUInt64() % validCountInRange; // Map the drawn index onto the k-th non-excluded ordinal of the interval: every excluded ordinal at // or below the candidate shifts it up by one. Sorted ascending, so a single pass suffices. foreach (ulong value in excluded) { @@ -214,6 +310,11 @@ private OrdinalIntervalSpec Validated(OrdinalIntervalSpec candidate, string appl private bool IsSatisfiable() { if (_effectiveAllowed is not null) { return _effectiveAllowed.Count > 0; } + if (_step > 1UL) { + if (!_latticeHasPoint) { return false; } + + return (_max - _latticeFirst) / _step + 1UL > (ulong)_excludedOnLattice.Count; + } if (IsFullWidth()) { return true; } return _max - _min + 1 - (ulong)_excludedInRange.Count > 0; @@ -228,6 +329,10 @@ private string DescribeExhaustion() { return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; } + if (_step > 1UL) { + return $"no {_typeName} value {_stepConstraint} allows remains between {_render(_min)} and {_render(_max)}"; + } + if (_min == _max) { string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; diff --git a/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 7beea317..8615fd81 100644 --- a/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -17,6 +17,7 @@ Dummies.AnyByte.GreaterThan(byte value) -> Dummies.AnyByte! Dummies.AnyByte.GreaterThanOrEqualTo(byte value) -> Dummies.AnyByte! Dummies.AnyByte.LessThan(byte value) -> Dummies.AnyByte! Dummies.AnyByte.LessThanOrEqualTo(byte value) -> Dummies.AnyByte! +Dummies.AnyByte.MultipleOf(byte value) -> Dummies.AnyByte! Dummies.AnyByte.NonZero() -> Dummies.AnyByte! Dummies.AnyByte.OneOf(params byte[]! values) -> Dummies.AnyByte! Dummies.AnyByte.Zero() -> Dummies.AnyByte! @@ -92,6 +93,7 @@ Dummies.AnyDateTime.DifferentFrom(System.DateTime value) -> Dummies.AnyDateTime! Dummies.AnyDateTime.Except(params System.DateTime[]! values) -> Dummies.AnyDateTime! Dummies.AnyDateTime.Generate() -> System.DateTime Dummies.AnyDateTime.OneOf(params System.DateTime[]! values) -> Dummies.AnyDateTime! +Dummies.AnyDateTime.WithGranularity(System.TimeSpan granularity) -> Dummies.AnyDateTime! Dummies.AnyDateTimeOffset Dummies.AnyDateTimeOffset.After(System.DateTimeOffset instant) -> Dummies.AnyDateTimeOffset! Dummies.AnyDateTimeOffset.AfterOrEqualTo(System.DateTimeOffset instant) -> Dummies.AnyDateTimeOffset! @@ -102,6 +104,7 @@ Dummies.AnyDateTimeOffset.DifferentFrom(System.DateTimeOffset value) -> Dummies. Dummies.AnyDateTimeOffset.Except(params System.DateTimeOffset[]! values) -> Dummies.AnyDateTimeOffset! Dummies.AnyDateTimeOffset.Generate() -> System.DateTimeOffset Dummies.AnyDateTimeOffset.OneOf(params System.DateTimeOffset[]! values) -> Dummies.AnyDateTimeOffset! +Dummies.AnyDateTimeOffset.WithGranularity(System.TimeSpan granularity) -> Dummies.AnyDateTimeOffset! Dummies.AnyDecimal Dummies.AnyDecimal.Between(decimal minimum, decimal maximum) -> Dummies.AnyDecimal! Dummies.AnyDecimal.DifferentFrom(decimal value) -> Dummies.AnyDecimal! @@ -115,6 +118,7 @@ Dummies.AnyDecimal.Negative() -> Dummies.AnyDecimal! Dummies.AnyDecimal.NonZero() -> Dummies.AnyDecimal! Dummies.AnyDecimal.OneOf(params decimal[]! values) -> Dummies.AnyDecimal! Dummies.AnyDecimal.Positive() -> Dummies.AnyDecimal! +Dummies.AnyDecimal.WithScale(int scale) -> Dummies.AnyDecimal! Dummies.AnyDecimal.Zero() -> Dummies.AnyDecimal! Dummies.AnyDictionary Dummies.AnyDictionary.ContainingAnyKey(Dummies.IAny! generator) -> Dummies.AnyDictionary! @@ -194,6 +198,7 @@ Dummies.AnyInt128.GreaterThan(System.Int128 value) -> Dummies.AnyInt128! Dummies.AnyInt128.GreaterThanOrEqualTo(System.Int128 value) -> Dummies.AnyInt128! Dummies.AnyInt128.LessThan(System.Int128 value) -> Dummies.AnyInt128! Dummies.AnyInt128.LessThanOrEqualTo(System.Int128 value) -> Dummies.AnyInt128! +Dummies.AnyInt128.MultipleOf(System.Int128 value) -> Dummies.AnyInt128! Dummies.AnyInt128.Negative() -> Dummies.AnyInt128! Dummies.AnyInt128.NonZero() -> Dummies.AnyInt128! Dummies.AnyInt128.OneOf(params System.Int128[]! values) -> Dummies.AnyInt128! @@ -208,6 +213,7 @@ Dummies.AnyInt16.GreaterThan(short value) -> Dummies.AnyInt16! Dummies.AnyInt16.GreaterThanOrEqualTo(short value) -> Dummies.AnyInt16! Dummies.AnyInt16.LessThan(short value) -> Dummies.AnyInt16! Dummies.AnyInt16.LessThanOrEqualTo(short value) -> Dummies.AnyInt16! +Dummies.AnyInt16.MultipleOf(short value) -> Dummies.AnyInt16! Dummies.AnyInt16.Negative() -> Dummies.AnyInt16! Dummies.AnyInt16.NonZero() -> Dummies.AnyInt16! Dummies.AnyInt16.OneOf(params short[]! values) -> Dummies.AnyInt16! @@ -222,6 +228,7 @@ Dummies.AnyInt32.GreaterThan(int value) -> Dummies.AnyInt32! Dummies.AnyInt32.GreaterThanOrEqualTo(int value) -> Dummies.AnyInt32! Dummies.AnyInt32.LessThan(int value) -> Dummies.AnyInt32! Dummies.AnyInt32.LessThanOrEqualTo(int value) -> Dummies.AnyInt32! +Dummies.AnyInt32.MultipleOf(int value) -> Dummies.AnyInt32! Dummies.AnyInt32.Negative() -> Dummies.AnyInt32! Dummies.AnyInt32.NonZero() -> Dummies.AnyInt32! Dummies.AnyInt32.OneOf(params int[]! values) -> Dummies.AnyInt32! @@ -236,6 +243,7 @@ Dummies.AnyInt64.GreaterThan(long value) -> Dummies.AnyInt64! Dummies.AnyInt64.GreaterThanOrEqualTo(long value) -> Dummies.AnyInt64! Dummies.AnyInt64.LessThan(long value) -> Dummies.AnyInt64! Dummies.AnyInt64.LessThanOrEqualTo(long value) -> Dummies.AnyInt64! +Dummies.AnyInt64.MultipleOf(long value) -> Dummies.AnyInt64! Dummies.AnyInt64.Negative() -> Dummies.AnyInt64! Dummies.AnyInt64.NonZero() -> Dummies.AnyInt64! Dummies.AnyInt64.OneOf(params long[]! values) -> Dummies.AnyInt64! @@ -268,6 +276,7 @@ Dummies.AnySByte.GreaterThan(sbyte value) -> Dummies.AnySByte! Dummies.AnySByte.GreaterThanOrEqualTo(sbyte value) -> Dummies.AnySByte! Dummies.AnySByte.LessThan(sbyte value) -> Dummies.AnySByte! Dummies.AnySByte.LessThanOrEqualTo(sbyte value) -> Dummies.AnySByte! +Dummies.AnySByte.MultipleOf(sbyte value) -> Dummies.AnySByte! Dummies.AnySByte.Negative() -> Dummies.AnySByte! Dummies.AnySByte.NonZero() -> Dummies.AnySByte! Dummies.AnySByte.OneOf(params sbyte[]! values) -> Dummies.AnySByte! @@ -323,6 +332,7 @@ Dummies.AnyTimeOnly.DifferentFrom(System.TimeOnly value) -> Dummies.AnyTimeOnly! Dummies.AnyTimeOnly.Except(params System.TimeOnly[]! values) -> Dummies.AnyTimeOnly! Dummies.AnyTimeOnly.Generate() -> System.TimeOnly Dummies.AnyTimeOnly.OneOf(params System.TimeOnly[]! values) -> Dummies.AnyTimeOnly! +Dummies.AnyTimeOnly.WithGranularity(System.TimeSpan granularity) -> Dummies.AnyTimeOnly! Dummies.AnyTimeSpan Dummies.AnyTimeSpan.Between(System.TimeSpan minimum, System.TimeSpan maximum) -> Dummies.AnyTimeSpan! Dummies.AnyTimeSpan.DifferentFrom(System.TimeSpan value) -> Dummies.AnyTimeSpan! @@ -336,6 +346,7 @@ Dummies.AnyTimeSpan.Negative() -> Dummies.AnyTimeSpan! Dummies.AnyTimeSpan.NonZero() -> Dummies.AnyTimeSpan! Dummies.AnyTimeSpan.OneOf(params System.TimeSpan[]! values) -> Dummies.AnyTimeSpan! Dummies.AnyTimeSpan.Positive() -> Dummies.AnyTimeSpan! +Dummies.AnyTimeSpan.WithGranularity(System.TimeSpan granularity) -> Dummies.AnyTimeSpan! Dummies.AnyTimeSpan.Zero() -> Dummies.AnyTimeSpan! Dummies.AnyUInt128 Dummies.AnyUInt128.Between(System.UInt128 minimum, System.UInt128 maximum) -> Dummies.AnyUInt128! @@ -346,6 +357,7 @@ Dummies.AnyUInt128.GreaterThan(System.UInt128 value) -> Dummies.AnyUInt128! Dummies.AnyUInt128.GreaterThanOrEqualTo(System.UInt128 value) -> Dummies.AnyUInt128! Dummies.AnyUInt128.LessThan(System.UInt128 value) -> Dummies.AnyUInt128! Dummies.AnyUInt128.LessThanOrEqualTo(System.UInt128 value) -> Dummies.AnyUInt128! +Dummies.AnyUInt128.MultipleOf(System.UInt128 value) -> Dummies.AnyUInt128! Dummies.AnyUInt128.NonZero() -> Dummies.AnyUInt128! Dummies.AnyUInt128.OneOf(params System.UInt128[]! values) -> Dummies.AnyUInt128! Dummies.AnyUInt128.Zero() -> Dummies.AnyUInt128! @@ -358,6 +370,7 @@ Dummies.AnyUInt16.GreaterThan(ushort value) -> Dummies.AnyUInt16! Dummies.AnyUInt16.GreaterThanOrEqualTo(ushort value) -> Dummies.AnyUInt16! Dummies.AnyUInt16.LessThan(ushort value) -> Dummies.AnyUInt16! Dummies.AnyUInt16.LessThanOrEqualTo(ushort value) -> Dummies.AnyUInt16! +Dummies.AnyUInt16.MultipleOf(ushort value) -> Dummies.AnyUInt16! Dummies.AnyUInt16.NonZero() -> Dummies.AnyUInt16! Dummies.AnyUInt16.OneOf(params ushort[]! values) -> Dummies.AnyUInt16! Dummies.AnyUInt16.Zero() -> Dummies.AnyUInt16! @@ -370,6 +383,7 @@ Dummies.AnyUInt32.GreaterThan(uint value) -> Dummies.AnyUInt32! Dummies.AnyUInt32.GreaterThanOrEqualTo(uint value) -> Dummies.AnyUInt32! Dummies.AnyUInt32.LessThan(uint value) -> Dummies.AnyUInt32! Dummies.AnyUInt32.LessThanOrEqualTo(uint value) -> Dummies.AnyUInt32! +Dummies.AnyUInt32.MultipleOf(uint value) -> Dummies.AnyUInt32! Dummies.AnyUInt32.NonZero() -> Dummies.AnyUInt32! Dummies.AnyUInt32.OneOf(params uint[]! values) -> Dummies.AnyUInt32! Dummies.AnyUInt32.Zero() -> Dummies.AnyUInt32! @@ -382,6 +396,7 @@ Dummies.AnyUInt64.GreaterThan(ulong value) -> Dummies.AnyUInt64! Dummies.AnyUInt64.GreaterThanOrEqualTo(ulong value) -> Dummies.AnyUInt64! Dummies.AnyUInt64.LessThan(ulong value) -> Dummies.AnyUInt64! Dummies.AnyUInt64.LessThanOrEqualTo(ulong value) -> Dummies.AnyUInt64! +Dummies.AnyUInt64.MultipleOf(ulong value) -> Dummies.AnyUInt64! Dummies.AnyUInt64.NonZero() -> Dummies.AnyUInt64! Dummies.AnyUInt64.OneOf(params ulong[]! values) -> Dummies.AnyUInt64! Dummies.AnyUInt64.Zero() -> Dummies.AnyUInt64! diff --git a/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 8d2c638f..22d72f57 100644 --- a/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -17,6 +17,7 @@ Dummies.AnyByte.GreaterThan(byte value) -> Dummies.AnyByte! Dummies.AnyByte.GreaterThanOrEqualTo(byte value) -> Dummies.AnyByte! Dummies.AnyByte.LessThan(byte value) -> Dummies.AnyByte! Dummies.AnyByte.LessThanOrEqualTo(byte value) -> Dummies.AnyByte! +Dummies.AnyByte.MultipleOf(byte value) -> Dummies.AnyByte! Dummies.AnyByte.NonZero() -> Dummies.AnyByte! Dummies.AnyByte.OneOf(params byte[]! values) -> Dummies.AnyByte! Dummies.AnyByte.Zero() -> Dummies.AnyByte! @@ -77,6 +78,7 @@ Dummies.AnyDateTime.DifferentFrom(System.DateTime value) -> Dummies.AnyDateTime! Dummies.AnyDateTime.Except(params System.DateTime[]! values) -> Dummies.AnyDateTime! Dummies.AnyDateTime.Generate() -> System.DateTime Dummies.AnyDateTime.OneOf(params System.DateTime[]! values) -> Dummies.AnyDateTime! +Dummies.AnyDateTime.WithGranularity(System.TimeSpan granularity) -> Dummies.AnyDateTime! Dummies.AnyDateTimeOffset Dummies.AnyDateTimeOffset.After(System.DateTimeOffset instant) -> Dummies.AnyDateTimeOffset! Dummies.AnyDateTimeOffset.AfterOrEqualTo(System.DateTimeOffset instant) -> Dummies.AnyDateTimeOffset! @@ -87,6 +89,7 @@ Dummies.AnyDateTimeOffset.DifferentFrom(System.DateTimeOffset value) -> Dummies. Dummies.AnyDateTimeOffset.Except(params System.DateTimeOffset[]! values) -> Dummies.AnyDateTimeOffset! Dummies.AnyDateTimeOffset.Generate() -> System.DateTimeOffset Dummies.AnyDateTimeOffset.OneOf(params System.DateTimeOffset[]! values) -> Dummies.AnyDateTimeOffset! +Dummies.AnyDateTimeOffset.WithGranularity(System.TimeSpan granularity) -> Dummies.AnyDateTimeOffset! Dummies.AnyDecimal Dummies.AnyDecimal.Between(decimal minimum, decimal maximum) -> Dummies.AnyDecimal! Dummies.AnyDecimal.DifferentFrom(decimal value) -> Dummies.AnyDecimal! @@ -100,6 +103,7 @@ Dummies.AnyDecimal.Negative() -> Dummies.AnyDecimal! Dummies.AnyDecimal.NonZero() -> Dummies.AnyDecimal! Dummies.AnyDecimal.OneOf(params decimal[]! values) -> Dummies.AnyDecimal! Dummies.AnyDecimal.Positive() -> Dummies.AnyDecimal! +Dummies.AnyDecimal.WithScale(int scale) -> Dummies.AnyDecimal! Dummies.AnyDecimal.Zero() -> Dummies.AnyDecimal! Dummies.AnyDictionary Dummies.AnyDictionary.ContainingAnyKey(Dummies.IAny! generator) -> Dummies.AnyDictionary! @@ -165,6 +169,7 @@ Dummies.AnyInt16.GreaterThan(short value) -> Dummies.AnyInt16! Dummies.AnyInt16.GreaterThanOrEqualTo(short value) -> Dummies.AnyInt16! Dummies.AnyInt16.LessThan(short value) -> Dummies.AnyInt16! Dummies.AnyInt16.LessThanOrEqualTo(short value) -> Dummies.AnyInt16! +Dummies.AnyInt16.MultipleOf(short value) -> Dummies.AnyInt16! Dummies.AnyInt16.Negative() -> Dummies.AnyInt16! Dummies.AnyInt16.NonZero() -> Dummies.AnyInt16! Dummies.AnyInt16.OneOf(params short[]! values) -> Dummies.AnyInt16! @@ -179,6 +184,7 @@ Dummies.AnyInt32.GreaterThan(int value) -> Dummies.AnyInt32! Dummies.AnyInt32.GreaterThanOrEqualTo(int value) -> Dummies.AnyInt32! Dummies.AnyInt32.LessThan(int value) -> Dummies.AnyInt32! Dummies.AnyInt32.LessThanOrEqualTo(int value) -> Dummies.AnyInt32! +Dummies.AnyInt32.MultipleOf(int value) -> Dummies.AnyInt32! Dummies.AnyInt32.Negative() -> Dummies.AnyInt32! Dummies.AnyInt32.NonZero() -> Dummies.AnyInt32! Dummies.AnyInt32.OneOf(params int[]! values) -> Dummies.AnyInt32! @@ -193,6 +199,7 @@ Dummies.AnyInt64.GreaterThan(long value) -> Dummies.AnyInt64! Dummies.AnyInt64.GreaterThanOrEqualTo(long value) -> Dummies.AnyInt64! Dummies.AnyInt64.LessThan(long value) -> Dummies.AnyInt64! Dummies.AnyInt64.LessThanOrEqualTo(long value) -> Dummies.AnyInt64! +Dummies.AnyInt64.MultipleOf(long value) -> Dummies.AnyInt64! Dummies.AnyInt64.Negative() -> Dummies.AnyInt64! Dummies.AnyInt64.NonZero() -> Dummies.AnyInt64! Dummies.AnyInt64.OneOf(params long[]! values) -> Dummies.AnyInt64! @@ -225,6 +232,7 @@ Dummies.AnySByte.GreaterThan(sbyte value) -> Dummies.AnySByte! Dummies.AnySByte.GreaterThanOrEqualTo(sbyte value) -> Dummies.AnySByte! Dummies.AnySByte.LessThan(sbyte value) -> Dummies.AnySByte! Dummies.AnySByte.LessThanOrEqualTo(sbyte value) -> Dummies.AnySByte! +Dummies.AnySByte.MultipleOf(sbyte value) -> Dummies.AnySByte! Dummies.AnySByte.Negative() -> Dummies.AnySByte! Dummies.AnySByte.NonZero() -> Dummies.AnySByte! Dummies.AnySByte.OneOf(params sbyte[]! values) -> Dummies.AnySByte! @@ -283,6 +291,7 @@ Dummies.AnyTimeSpan.Negative() -> Dummies.AnyTimeSpan! Dummies.AnyTimeSpan.NonZero() -> Dummies.AnyTimeSpan! Dummies.AnyTimeSpan.OneOf(params System.TimeSpan[]! values) -> Dummies.AnyTimeSpan! Dummies.AnyTimeSpan.Positive() -> Dummies.AnyTimeSpan! +Dummies.AnyTimeSpan.WithGranularity(System.TimeSpan granularity) -> Dummies.AnyTimeSpan! Dummies.AnyTimeSpan.Zero() -> Dummies.AnyTimeSpan! Dummies.AnyUInt16 Dummies.AnyUInt16.Between(ushort minimum, ushort maximum) -> Dummies.AnyUInt16! @@ -293,6 +302,7 @@ Dummies.AnyUInt16.GreaterThan(ushort value) -> Dummies.AnyUInt16! Dummies.AnyUInt16.GreaterThanOrEqualTo(ushort value) -> Dummies.AnyUInt16! Dummies.AnyUInt16.LessThan(ushort value) -> Dummies.AnyUInt16! Dummies.AnyUInt16.LessThanOrEqualTo(ushort value) -> Dummies.AnyUInt16! +Dummies.AnyUInt16.MultipleOf(ushort value) -> Dummies.AnyUInt16! Dummies.AnyUInt16.NonZero() -> Dummies.AnyUInt16! Dummies.AnyUInt16.OneOf(params ushort[]! values) -> Dummies.AnyUInt16! Dummies.AnyUInt16.Zero() -> Dummies.AnyUInt16! @@ -305,6 +315,7 @@ Dummies.AnyUInt32.GreaterThan(uint value) -> Dummies.AnyUInt32! Dummies.AnyUInt32.GreaterThanOrEqualTo(uint value) -> Dummies.AnyUInt32! Dummies.AnyUInt32.LessThan(uint value) -> Dummies.AnyUInt32! Dummies.AnyUInt32.LessThanOrEqualTo(uint value) -> Dummies.AnyUInt32! +Dummies.AnyUInt32.MultipleOf(uint value) -> Dummies.AnyUInt32! Dummies.AnyUInt32.NonZero() -> Dummies.AnyUInt32! Dummies.AnyUInt32.OneOf(params uint[]! values) -> Dummies.AnyUInt32! Dummies.AnyUInt32.Zero() -> Dummies.AnyUInt32! @@ -317,6 +328,7 @@ Dummies.AnyUInt64.GreaterThan(ulong value) -> Dummies.AnyUInt64! Dummies.AnyUInt64.GreaterThanOrEqualTo(ulong value) -> Dummies.AnyUInt64! Dummies.AnyUInt64.LessThan(ulong value) -> Dummies.AnyUInt64! Dummies.AnyUInt64.LessThanOrEqualTo(ulong value) -> Dummies.AnyUInt64! +Dummies.AnyUInt64.MultipleOf(ulong value) -> Dummies.AnyUInt64! Dummies.AnyUInt64.NonZero() -> Dummies.AnyUInt64! Dummies.AnyUInt64.OneOf(params ulong[]! values) -> Dummies.AnyUInt64! Dummies.AnyUInt64.Zero() -> Dummies.AnyUInt64! diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index ff68a22e..6e4848f2 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -71,6 +71,16 @@ matter — and that is the point. `After`/`Before`/`Between`, quantities with `Positive`/`Between`/`NonZero`, identities with `NonEmpty`/`DifferentFrom` — and deliberately no clock-relative constraints: a reproducible test pins its reference instants explicitly. +- **Values on a grid**: a quantity that must be a whole number of some unit takes + `MultipleOf` — `Any.Int32().Between(0, 100_000).MultipleOf(100)` for an amount in whole + euros held as cents — drawn *on* the grid so the declared range keeps its meaning, + instead of an `As(x => x * 100)` projection that silently distorts it. `Decimal` takes + `WithScale(n)`, a value expressible in `n` decimal places (`WithScale(2)` for a currency + amount) — a *value* lattice (a multiple of `10⁻ⁿ`), not a padded representation. The + temporal generators take `WithGranularity(TimeSpan)` — a round instant or duration + (`WithGranularity(TimeSpan.FromMinutes(15))`) — so tick-precision values never surprise a + serialization round-trip. Each is built in one draw, composes with the bounds and + exclusions, and conflicts eagerly when the range holds no grid point. - **Values built to satisfy the constraints** — a scalar is constructed directly, never generated-then-filtered. The one exception is excluding values from a string (`Any.String().DifferentFrom(...)`/`Except(...)`): a string has no ordinal mapping to diff --git a/Dummies/WideIntervalSpec.cs b/Dummies/WideIntervalSpec.cs index b297ca16..875c9f7b 100644 --- a/Dummies/WideIntervalSpec.cs +++ b/Dummies/WideIntervalSpec.cs @@ -4,37 +4,70 @@ namespace Dummies; /// /// The 128-bit sibling of , backing and /// : their ordinal space exceeds 64 bits, so the same algebra — descriptor-tracked -/// inclusive bounds, allow-list, exclusions, eager conflicts, one-draw generation — runs over -/// ordinals. Net8-only, like the types it serves. +/// inclusive bounds, allow-list, exclusions, an optional lattice (MultipleOf), eager conflicts, one-draw +/// generation — runs over ordinals. Net8-only, like the types it serves. /// internal sealed class WideIntervalSpec { #region Statics members declarations internal static WideIntervalSpec Unconstrained(string typeName, Func render, UInt128 domainMin, UInt128 domainMax) { - return new WideIntervalSpec(typeName, render, domainMin, domainMax, domainMin, null, domainMax, null, null, null, []); + return new WideIntervalSpec(typeName, render, domainMin, domainMax, domainMin, null, domainMax, null, null, null, [], UInt128.One, UInt128.Zero, null); } private static UInt128 NextUInt128(Random random) { return new UInt128(random.NextUInt64(), random.NextUInt64()); } + /// Whether sits on the lattice anchored at with the given step. + private static bool IsOnLattice(UInt128 ordinal, UInt128 anchor, UInt128 step) { + UInt128 delta = ordinal >= anchor ? ordinal - anchor : anchor - ordinal; + + return delta % step == UInt128.Zero; + } + + /// + /// The smallest lattice ordinal at or above , staying within [min, max]. Returns + /// false when none exists (the stride steps past , or the domain top overflows). + /// + private static bool TryFirstLatticePoint(UInt128 min, UInt128 max, UInt128 anchor, UInt128 step, out UInt128 first) { + if (min >= anchor) { + UInt128 ahead = (min - anchor) % step; + if (ahead == UInt128.Zero) { + first = min; + } else { + first = min + (step - ahead); + if (first < min) { first = UInt128.Zero; return false; } // wrapped past the top of the ordinal domain + } + } else { + first = min + (anchor - min) % step; + } + + return first <= max; + } + #endregion #region Fields declarations private readonly IReadOnlyList? _allowed; private readonly string? _allowedConstraint; + private readonly UInt128 _anchor; private readonly UInt128 _domainMax; private readonly List? _effectiveAllowed; private readonly List _excludedInRange; + private readonly List _excludedOnLattice; private readonly UInt128 _domainMin; private readonly IReadOnlyList _excluded; + private readonly UInt128 _latticeFirst; + private readonly bool _latticeHasPoint; private readonly UInt128 _max; private readonly string? _maxConstraint; private readonly UInt128 _min; private readonly string? _minConstraint; private readonly Func _render; + private readonly UInt128 _step; + private readonly string? _stepConstraint; private readonly string _typeName; #endregion @@ -43,7 +76,8 @@ private WideIntervalSpec(string typeName, Func render, UInt128 UInt128 min, string? minConstraint, UInt128 max, string? maxConstraint, IReadOnlyList? allowed, string? allowedConstraint, - IReadOnlyList excluded) { + IReadOnlyList excluded, + UInt128 step, UInt128 anchor, string? stepConstraint) { _typeName = typeName; _render = render; _domainMin = domainMin; @@ -55,12 +89,24 @@ private WideIntervalSpec(string typeName, Func render, UInt128 _allowed = allowed; _allowedConstraint = allowedConstraint; _excluded = excluded; + _step = step; + _anchor = anchor; + _stepConstraint = stepConstraint; // Materialized once here — "constrain once, draw many": GenerateOrdinal never refilters or resorts. _excludedInRange = excluded.Where(value => value >= min && value <= max).Distinct().ToList(); _excludedInRange.Sort(); + if (step > UInt128.One) { + _latticeHasPoint = TryFirstLatticePoint(min, max, anchor, step, out _latticeFirst); + _excludedOnLattice = _excludedInRange.Where(value => IsOnLattice(value, anchor, step)).ToList(); // stays sorted: filtered from a sorted list + } else { + _latticeHasPoint = true; + _latticeFirst = min; + _excludedOnLattice = _excludedInRange; + } + if (allowed is not null) { HashSet forbidden = new(excluded); - _effectiveAllowed = allowed.Where(value => value >= min && value <= max && !forbidden.Contains(value)).ToList(); + _effectiveAllowed = allowed.Where(value => value >= min && value <= max && !forbidden.Contains(value) && (step <= UInt128.One || IsOnLattice(value, anchor, step))).ToList(); } } @@ -74,7 +120,7 @@ internal WideIntervalSpec WithMinimum(UInt128 minimum, string applying) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_maxConstraint} already requires values less than or equal to {_render(_max)}."); } - return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _excluded), applying); + return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _excluded, _step, _anchor, _stepConstraint), applying); } /// Tightens the lower bound to strictly above — the exclusive form of . @@ -94,7 +140,7 @@ internal WideIntervalSpec WithMaximum(UInt128 maximum, string applying) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_minConstraint} already requires values greater than or equal to {_render(_min)}."); } - return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _excluded), applying); + return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _excluded, _step, _anchor, _stepConstraint), applying); } /// Tightens the upper bound to strictly below — the exclusive form of . @@ -110,7 +156,7 @@ internal WideIntervalSpec WithAllowed(UInt128[] ordinals, string applying) { UInt128[] distinct = ordinals.Distinct().ToArray(); - return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, distinct, applying, _excluded), applying); + return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, distinct, applying, _excluded, _step, _anchor, _stepConstraint), applying); } /// Adds values the generator must never produce. @@ -118,7 +164,24 @@ internal WideIntervalSpec WithExcluded(UInt128[] ordinals, string applying) { List excluded = new(_excluded); excluded.AddRange(ordinals); - return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded), applying); + return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded, _step, _anchor, _stepConstraint), applying); + } + + /// + /// Restricts the domain to a lattice: values a multiple of away from + /// — a known lattice ordinal, the ordinal of the value 0. Declared once per + /// generator. + /// + internal WideIntervalSpec WithStep(UInt128 step, UInt128 anchor, string applying) { + if (step <= UInt128.One) { return this; } // every value is a multiple of one: a no-op, not a constraint + + if (_step > UInt128.One) { + if (_step == step && _anchor == anchor) { return this; } + + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_stepConstraint} is already defined."); + } + + return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, _excluded, step, anchor, applying), applying); } /// @@ -130,6 +193,13 @@ internal WideIntervalSpec WithExcluded(UInt128[] ordinals, string applying) { internal long? Cardinality { get { if (_effectiveAllowed is not null) { return _effectiveAllowed.Count; } + if (_step > UInt128.One) { + if (!_latticeHasPoint) { return 0; } + + UInt128 onLattice = (_max - _latticeFirst) / _step + 1 - (UInt128)_excludedOnLattice.Count; + + return onLattice <= (UInt128)long.MaxValue ? (long)onLattice : null; + } if (IsFullWidth()) { return null; } UInt128 count = _max - _min + 1 - (UInt128)_excludedInRange.Count; @@ -144,6 +214,7 @@ internal long? Cardinality { /// internal bool Contains(UInt128 ordinal) { if (_effectiveAllowed is not null) { return _effectiveAllowed.Contains(ordinal); } + if (_step > UInt128.One && !IsOnLattice(ordinal, _anchor, _step)) { return false; } return ordinal >= _min && ordinal <= _max && !_excludedInRange.Contains(ordinal); } @@ -154,6 +225,20 @@ internal UInt128 GenerateOrdinal(Random random) { return _effectiveAllowed[random.Next(_effectiveAllowed.Count)]; } + if (_step > UInt128.One) { + // The lattice caps the count below the full 128-bit width, so the full-width special case never + // applies here. Draw an index over the surviving lattice points, then shift past any excluded + // lattice point at or below the drawn ordinal. + UInt128 latticeCount = (_max - _latticeFirst) / _step + 1; + UInt128 validCount = latticeCount - (UInt128)_excludedOnLattice.Count; + UInt128 ordinal = _latticeFirst + NextUInt128(random) % validCount * _step; + foreach (UInt128 value in _excludedOnLattice) { + if (ordinal >= value) { ordinal += _step; } + } + + return ordinal; + } + List excluded = _excludedInRange; if (IsFullWidth()) { // Same escape as OrdinalIntervalSpec: the full 128-bit space has no representable size, so draw @@ -185,6 +270,11 @@ private WideIntervalSpec Validated(WideIntervalSpec candidate, string applying) private bool IsSatisfiable() { if (_effectiveAllowed is not null) { return _effectiveAllowed.Count > 0; } + if (_step > UInt128.One) { + if (!_latticeHasPoint) { return false; } + + return (_max - _latticeFirst) / _step + 1 > (UInt128)_excludedOnLattice.Count; + } if (IsFullWidth()) { return true; } return _max - _min + 1 - (UInt128)_excludedInRange.Count > 0; @@ -199,6 +289,10 @@ private string DescribeExhaustion() { return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; } + if (_step > UInt128.One) { + return $"no {_typeName} value {_stepConstraint} allows remains between {_render(_min)} and {_render(_max)}"; + } + if (_min == _max) { string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; diff --git a/doc/handwritten/for-maintainers/adr/0036-draw-lattice-constrained-scalars-on-the-grid.fr.md b/doc/handwritten/for-maintainers/adr/0036-draw-lattice-constrained-scalars-on-the-grid.fr.md new file mode 100644 index 00000000..b725e009 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0036-draw-lattice-constrained-scalars-on-the-grid.fr.md @@ -0,0 +1,84 @@ +# ADR-0036 | Tirer les scalaires contraints à un réseau sur la grille + +🌍 🇬🇧 [English](0036-draw-lattice-constrained-scalars-on-the-grid.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Proposé +**Date :** 2026-07-26 +**Décideurs :** Reefact + +## Contexte + +Dummies construit un scalaire directement pour satisfaire ses contraintes — jamais généré-puis-filtré —, détecte les contradictions au moment de la déclaration, et évite les boucles de nouvelles tentatives cachées et non bornées. Un générateur scalaire qui existe peut toujours générer, en un seul tirage. Les types à projection ordinale (les entiers, les temporels) tirent le k-ième ordinal non exclu du domaine en une passe sur un espace ordinal affine et préservant l'ordre ; le `decimal` tire un candidat et le décale dans un budget borné. + +Un besoin récurrent est celui d'une valeur qui doit se situer sur une grille régulière : un multiple d'une unité (un montant en centimes entiers, une quantité à la douzaine), un `decimal` exprimable en un nombre fixe de décimales (un montant monétaire), ou un instant rond (une seconde pleine, un quart d'heure, un jour plein). Ce sont des invariants du code testé — un value object ou une précondition de contrat que la valeur doit respecter —, non ce que le test vérifie. + +Aujourd'hui, une telle valeur n'est atteignable qu'en projetant après coup une valeur contrainte, `As(x => x * k)`. La projection déforme la portée déclarée (une portée exprimée dans l'unité d'avant projection ne veut plus dire ce qu'elle affirme) et fait sortir la valeur de l'algèbre de contraintes : le générateur projeté ne peut plus exclure de valeurs, ne peut plus détecter de conflit, et ne porte aucun indice de cardinalité pour les collections distinctes. Les dummies temporels à précision de tick surprennent en outre les tests qui sérialisent via un format à la seconde ou au jour, où l'aller-retour perd silencieusement la précision. + +Parce que la projection ordinale est affine, les multiples d'un pas forment une progression arithmétique dans l'espace ordinal ; une grille est donc exprimable comme une dimension à part entière des moteurs d'intervalle sans quitter le modèle constructif. Les types à virgule flottante binaire n'ont pas de grille décimale (ni rationnelle générale) exacte — `0.1` n'y est pas représentable —, de sorte que la même construction ne peut y tenir. L'issue #226 recense `MultipleOf`/`WithScale` comme un ajout piloté par la demande ; le besoin de granularité temporelle y a été noté en parallèle. + +## Décision + +Une contrainte de réseau — `MultipleOf` sur les entiers, `WithScale` sur le `decimal`, `WithGranularity` sur les temporels — restreint un scalaire à une grille régulière tirée de manière constructive en une passe, se compose avec les bornes, exclusions et listes d'autorisation existantes, se déclare une seule fois par générateur, et est délibérément refusée aux types à virgule flottante binaire. + +## Justification + +Tirer sur la grille préserve l'invariant du tirage unique et sans nouvelle tentative : la projection ordinale affine fait des multiples d'un pas une progression arithmétique, de sorte que la grille devient une dimension de plus que le moteur d'intervalle échantillonne directement, plutôt qu'un post-filtre qui réintroduirait du rejet. Garder la valeur de première classe — au lieu d'une projection `As` — est tout l'enjeu : la portée déclarée conserve son sens, et les exclusions, les listes d'autorisation, la détection de conflit au moment de la déclaration et l'indice de cardinalité continuent de s'appliquer, si bien qu'une collection distincte sur une grille étroite échoue toujours par anticipation. + +`WithScale` est un réseau de *valeurs* — un multiple de `10⁻ⁿ` —, non un contrat de représentation qui compléterait les zéros de fin, car l'invariant dont les appelants ont réellement besoin est « une valeur que le domaine accepte » (une fabrique monétaire qui refuse une troisième décimale), ce qui est un fait sur la valeur, non sur le rendu. Une garantie de représentation ne se composerait pas avec l'égalité de valeurs et surprendrait quiconque compare `12.30` et `12.3`. + +Le réseau est refusé aux flottants binaires parce qu'une grille décimale n'y est pas exactement représentable ; l'offrir rendrait des valeurs hors grille sous une promesse que le type ne peut tenir. Il se déclare une seule fois — un second réseau différent entre en conflit plutôt que de s'intersecter silencieusement —, à l'image de la règle « déclaré une seule fois » qu'utilise déjà la liste d'autorisation, et cela épargne une combinaison par plus petit commun multiple que la demande ne justifie pas. Exposer une seule capacité du moteur comme `MultipleOf` sur les entiers et `WithGranularity` sur les temporels est ce qui permet à une seule dimension de servir les deux familles, de sorte qu'un correctif de la logique de grille atteint tous les types d'un coup. + +L'arithmétique du pas, le calage-et-décalage décimal et la formulation des messages de conflit relèvent de l'implémentation, documentée dans le code `Dummies` (`OrdinalIntervalSpec`, `WideIntervalSpec`, `DecimalIntervalSpec`) et dans la documentation utilisateur de Dummies — pas ici. + +## Alternatives envisagées + +### Conserver la projection `As(x => x * k)` comme seul moyen + +Envisagée parce qu'elle ne demande aucune nouvelle API et fonctionne déjà. Rejetée parce qu'elle déforme la portée déclarée, fait sortir la valeur de l'algèbre de contraintes (pas d'exclusion, pas de détection de conflit, pas d'indice de cardinalité) et — pour la précision temporelle — ne traite pas du tout la surprise de sérialisation. + +### Générer puis filtrer les tirages hors grille + +Envisagée parce que c'est la manière évidente d'honorer une grille arbitraire. Rejetée parce qu'elle réintroduit une boucle de nouvelles tentatives non bornée, en contradiction avec le modèle constructif et sans boucle cachée sur lequel la bibliothèque est bâtie. + +### Étendre le réseau aux types à virgule flottante binaire + +Envisagée pour la symétrie de surface avec les entiers et le `decimal`. Rejetée parce qu'une grille décimale (ou rationnelle générale) n'est pas exactement représentable en virgule flottante binaire ; la contrainte rendrait donc des valeurs hors grille — une fausse promesse pire qu'un manque délibéré et documenté. + +### Faire de `WithScale` un contrat de représentation + +Envisagée parce que le nom évoque `decimal.Scale` et une colonne de base de données `DECIMAL(p, s)`. Rejetée parce que l'invariant dont les appelants ont besoin est au niveau de la valeur, qu'une garantie de représentation ne se compose pas avec l'égalité de valeurs, et qu'elle surprendrait sur `12.30 == 12.3`. + +### Combiner les réseaux répétés par plus petit commun multiple + +Envisagée parce que « multiple de 4 et de 6 » est mathématiquement « multiple de 12 », non une contradiction. Rejetée comme disproportionnée : elle ouvre un cas limite propice au dépassement pour une combinaison que la demande ne montre pas, alors que « déclaré une seule fois » est simple, sûr et cohérent avec la liste d'autorisation. + +## Conséquences + +### Positives + +* L'invariant « valeur sur une grille » est exprimable de manière constructive : la portée déclarée reste honnête et la valeur conserve toute sa composition — bornes, exclusions, liste d'autorisation, conflit par anticipation, et l'indice de cardinalité qui laisse une collection distincte sur une grille étroite échouer par anticipation. +* Une seule capacité du moteur sert les entiers et les temporels (et le `decimal` par son propre moteur), de sorte qu'un correctif de la logique de grille atteint tous les types d'un coup. +* Le contournement `As(x => x * k)` et la surprise de sérialisation à la précision de tick disparaissent tous deux pour les types couverts. + +### Négatives + +* Une nouvelle dimension commutative vit désormais dans trois moteurs d'intervalle (ordinal, large, décimal) et doit y être maintenue de concert. +* La surface est délibérément asymétrique : les flottants binaires portent le vocabulaire de signe et de bornes mais aucun réseau — un manque que les utilisateurs doivent apprendre plutôt que déduire. + +### Risques + +* La distinction valeur-contre-représentation de `WithScale` peut surprendre les utilisateurs qui attendent une échelle complétée. Atténuation : l'énoncer comme un réseau de valeurs dans la documentation du builder et le readme. +* La grille décimale tire-et-cale au lieu d'énumérer, si bien que la masse sur les deux points de grille extrêmes est approximative. Atténuation : l'atteignabilité des deux bornes est préservée et testée, en cohérence avec le tirage décimal existant. + +## Actions de suivi + +* Documenter `MultipleOf`/`WithScale`/`WithGranularity` dans le readme de Dummies et la documentation des builders (fait dans la pull request d'implémentation). +* N'ajouter le sucre temporel `WholeSeconds()`/`WholeDays()` que si la demande apparaît ; le `WithGranularity(TimeSpan)` général le couvre en attendant. +* Ne revisiter la combinaison par plus petit commun multiple des réseaux répétés que si l'usage réel montre que la règle « déclaré une seule fois » est trop stricte. + +## Références + +* Issue [#226](https://github.com/Reefact/first-class-errors/issues/226) — le backlog piloté par la demande qui recense `MultipleOf`/`WithScale` et la granularité temporelle. +* [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) — l'indice de cardinalité qu'un réseau alimente, et le frère du tirage borné. +* [ADR-0020](0020-materialize-dummies-only-through-generate.md) — les dummies ne se matérialisent qu'à travers `Generate()`. +* `OrdinalIntervalSpec`, `WideIntervalSpec`, `DecimalIntervalSpec` et les builders concernés dans le projet `Dummies` ; le readme NuGet de Dummies. diff --git a/doc/handwritten/for-maintainers/adr/0036-draw-lattice-constrained-scalars-on-the-grid.md b/doc/handwritten/for-maintainers/adr/0036-draw-lattice-constrained-scalars-on-the-grid.md new file mode 100644 index 00000000..98a0ea15 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0036-draw-lattice-constrained-scalars-on-the-grid.md @@ -0,0 +1,84 @@ +# ADR-0036 | Draw lattice-constrained scalars on the grid + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0036-draw-lattice-constrained-scalars-on-the-grid.fr.md) + +**Status:** Proposed +**Date:** 2026-07-26 +**Decision Makers:** Reefact + +## Context + +Dummies builds a scalar directly to satisfy its constraints — never generated-then-filtered — detects contradictions eagerly at declaration, and avoids hidden unbounded retry loops. A scalar generator that exists can always generate, in one draw. The ordinal-mapped types (the integers, the temporals) draw the k-th non-excluded value of the domain in one pass over an order-preserving, affine ordinal space; `decimal` draws a candidate and nudges it within a bounded budget. + +A recurring dummy need is a value that must lie on a regular grid: a multiple of a unit (an amount in whole cents, a quantity in dozens), a `decimal` expressible in a fixed number of places (a currency amount), or a round instant (a whole second, a quarter-hour, a whole day). These are invariants of the code under test — a value object or contract precondition the value must satisfy — not what the test asserts. + +Today such a value can only be reached by projecting a constrained one after the fact, `As(x => x * k)`. The projection distorts the declared range (a range stated in the pre-projection unit no longer means what it says) and drops the value out of the constraint algebra: the projected generator can no longer exclude values, cannot conflict-check, and carries no cardinality hint for distinct collections. Tick-precision temporal dummies additionally surprise tests that serialize through a second- or day-granular format, where the round-trip silently drops precision. + +Because the ordinal map is affine, the multiples of a step form an arithmetic progression in ordinal space, so a grid is expressible as a first-class dimension of the interval engines without leaving the constructive model. Binary floating-point types have no exact base-ten (or general rational) grid — `0.1` is not representable — so the same construction cannot hold there. Issue #226 records `MultipleOf`/`WithScale` as a demand-driven addition; the temporal granularity need was noted alongside it. + +## Decision + +A lattice constraint — `MultipleOf` on the integers, `WithScale` on `decimal`, `WithGranularity` on the temporals — restricts a scalar to a regular grid drawn constructively in one pass, composes with the existing bounds, exclusions and allow-list, is declared once per generator, and is deliberately withheld from the binary floating-point types. + +## Rationale + +Drawing on the grid keeps the library's single-draw, no-retry invariant: the affine ordinal map makes the multiples of a step an arithmetic progression, so the grid becomes another dimension the interval engine samples directly rather than a post-filter that would reintroduce rejection. Keeping the value first-class — rather than an `As` projection — is the whole point: the declared range keeps its meaning, and exclusions, allow-lists, eager conflict detection and the cardinality hint continue to apply, so a distinct collection over a narrow grid still fails eagerly. + +`WithScale` is a *value* lattice — a multiple of `10⁻ⁿ` — not a representation contract that would pad trailing zeros, because the invariant callers actually need is "a value the domain accepts" (a money factory that rejects a third decimal place), which is a fact about value, not rendering. A representation guarantee would not compose with value equality and would surprise anyone comparing `12.30` with `12.3`. + +The lattice is withheld from the binary floats because a base-ten grid is not exactly representable there; offering it would hand back off-grid values under a promise the type cannot keep. It is declared once — a second, different grid conflicts rather than silently intersecting — mirroring the "declared once" rule the allow-list already uses and sparing a least-common-multiple combination the demand does not justify. Surfacing one engine capability as `MultipleOf` on integers and `WithGranularity` on temporals is what lets a single dimension serve both families, so a fix to the grid logic reaches every type at once. + +The step arithmetic, the decimal snap-and-nudge, and the conflict-message wording are implementation, documented in the `Dummies` code (`OrdinalIntervalSpec`, `WideIntervalSpec`, `DecimalIntervalSpec`) and the Dummies user documentation — not here. + +## Alternatives Considered + +### Keep the `As(x => x * k)` projection as the only way + +Considered because it needs no new API and already works. Rejected because it distorts the declared range, drops the value out of the constraint algebra (no exclusion, no conflict check, no cardinality hint), and — for temporal precision — does not address the serialization surprise at all. + +### Generate then filter off-grid draws + +Considered because it is the obvious way to honour an arbitrary grid. Rejected because it reintroduces an unbounded retry loop, contradicting the constructive, no-hidden-loops model the library is built on. + +### Extend the lattice to the binary floating-point types + +Considered for surface symmetry with the integers and `decimal`. Rejected because a base-ten (or general rational) grid is not exactly representable in binary floating point, so the constraint would return off-grid values — a false promise worse than a deliberate, documented gap. + +### Make `WithScale` a representation contract + +Considered because the name evokes `decimal.Scale` and a database `DECIMAL(p, s)` column. Rejected because the invariant callers need is value-level, a representation guarantee does not compose with value equality, and it would surprise on `12.30 == 12.3`. + +### Combine repeated lattices by least common multiple + +Considered because "multiple of 4 and of 6" is mathematically "multiple of 12", not a contradiction. Rejected as disproportionate: it opens an overflow-prone corner for a combination the demand does not show, whereas "declared once" is simple, safe, and consistent with the allow-list. + +## Consequences + +### Positive + +* The "value on a grid" invariant is expressible constructively, so the declared range stays honest and the value keeps full composition — bounds, exclusions, allow-list, eager conflict, and the cardinality hint that lets a distinct collection over a narrow grid fail eagerly. +* One engine capability serves the integers and the temporals (and `decimal` through its own engine), so a fix to the grid logic reaches every type at once. +* The `As(x => x * k)` workaround and the tick-precision serialization surprise both go away for the covered types. + +### Negative + +* A new commutative dimension now lives in three interval engines (ordinal, wide, decimal) and must be maintained in step across them. +* The surface is deliberately asymmetric: the binary floats carry the sign and bound vocabulary but no lattice — a gap users must learn rather than infer. + +### Risks + +* `WithScale`'s value-versus-representation distinction may surprise users expecting a padded scale. Mitigation: state it as a value lattice in the builder documentation and the readme. +* The decimal grid draws-and-snaps rather than enumerating, so the mass at the two extreme grid points is approximate. Mitigation: reachability of both bounds is preserved and tested, consistent with the existing decimal draw. + +## Follow-up Actions + +* Document `MultipleOf`/`WithScale`/`WithGranularity` in the Dummies readme and the builder documentation (done in the implementing pull request). +* Ship the `WholeSeconds()`/`WholeDays()` temporal sugar only if demand appears; the general `WithGranularity(TimeSpan)` covers it in the meantime. +* Revisit least-common-multiple combination of repeated lattices only if real usage shows the "declared once" rule is too strict. + +## References + +* Issue [#226](https://github.com/Reefact/first-class-errors/issues/226) — the demand-driven backlog that lists `MultipleOf`/`WithScale` and temporal granularity. +* [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) — the cardinality hint a lattice feeds, and the bounded-draw sibling. +* [ADR-0020](0020-materialize-dummies-only-through-generate.md) — dummies materialize only through `Generate()`. +* `OrdinalIntervalSpec`, `WideIntervalSpec`, `DecimalIntervalSpec` and the affected builders in the `Dummies` project; the Dummies NuGet readme. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index f8b2da83..ac8c9bc2 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -218,3 +218,4 @@ Optional supporting material: | [ADR-0033](0033-meet-string-exclusions-with-a-bounded-redraw.md) | Meet string exclusions with a bounded redraw | Accepted | | [ADR-0034](0034-require-a-scope-on-the-version-driving-commit-types.md) | Require a scope on the version-driving commit types | Accepted | | [ADR-0035](0035-enforce-structural-any-conflicts-at-compile-time.md) | Enforce structural Any conflicts at compile time, value-dependent ones at run time | Accepted | +| [ADR-0036](0036-draw-lattice-constrained-scalars-on-the-grid.md) | Draw lattice-constrained scalars on the grid | Proposed |