diff --git a/JustDummies.PropertyTests/ConflictMessageTruthfulnessProperties.cs b/JustDummies.PropertyTests/ConflictMessageTruthfulnessProperties.cs new file mode 100644 index 0000000..a21b865 --- /dev/null +++ b/JustDummies.PropertyTests/ConflictMessageTruthfulnessProperties.cs @@ -0,0 +1,208 @@ +#region Usings declarations + +using FsCheck; +using FsCheck.Fluent; + +using JetBrains.Annotations; + +#endregion + +namespace JustDummies.PropertyTests; + +/// +/// The one property in this suite that reads a conflict message rather than only its exception type. The +/// rule "assert exception types, never message text" (ADR-0040) exists because message wording is unstable; +/// the invariant proven here is not the wording but its truthfulness — for every legal combination of +/// constraints that empties the domain, the emitted message must make only claims that are literally true, must +/// name no falsehood, and must not echo the applied constraint on both sides of "because". That correctness has a +/// genuine input space (every bound / lattice / allow-list / exclusion combination) and no type-level proxy, so it +/// is a property, and the audit that first found the defect (issue #312: an allow-list narrowed by a bound was +/// reported as forbidden "entirely" by the exclusion) becomes a standing guard rather than a one-off check. +/// +/// +/// +/// The oracle recomputes feasibility independently over a small integer universe and rejects any message whose +/// universal claim ("forbids every …", "every value between …") is not backed by that ground truth, any bare +/// allow-list claim used when the allow-list was in fact narrowed by another constraint, and any "Cannot apply +/// X because X forbids …" echo. It is deliberately coupled to the stable claim fragments, not the full prose: +/// a reworded message that stops making a checkable claim would pass, but a message that makes a false +/// claim cannot. This property fails on the pre-fix engines (the audit counted tens of thousands of false +/// messages), which is the falsifiability the suite requires. +/// +/// +/// The engines share one exhaustion path, so a builder per family — ordinal (), decimal, +/// continuous () — proves them all; the 128-bit sibling is checked in +/// ModernTypeInvariantProperties, which the net472 floor leg excludes. +/// +/// +[TestSubject(typeof(ConflictingAnyConstraintException))] +public sealed class ConflictMessageTruthfulnessProperties { + + #region Statics members declarations + + /// Small, exact-in-every-numeric-type values; some (10) fall outside the generated Between window on purpose, to drive the narrowed-allow-list case. + internal static readonly int[] Universe = [0, 1, 2, 3, 5, 10]; + + /// Builds an engine's chain in the order Between?, MultipleOf?, OneOf?, Except?; returns the conflict message, or null when the chain is satisfiable. + internal delegate string? EngineBuilder(bool hasBetween, int lo, int hi, int step, int[] allow, int[] excl); + + /// Runs the truthfulness property against one engine, quantifying over the constraint combinations it supports. + internal static void CheckEngine(EngineBuilder build, bool supportsLattice) { + Gen<(bool HasBetween, int Lo, int Hi, int Step, int[] Allow, int[] Excl)> combos = + from hasBetween in Gen.Elements(true, false) + from a in Gen.Choose(0, 4) + from b in Gen.Choose(0, 4) + from step in Gen.Choose(1, supportsLattice ? 3 : 1) + from allowMask in Gen.Choose(0, (1 << Universe.Length) - 1) + from exclMask in Gen.Choose(0, (1 << Universe.Length) - 1) + select (hasBetween, Math.Min(a, b), Math.Max(a, b), step, Subset(allowMask), Subset(exclMask)); + + Prop.ForAll(combos.ToArbitrary(), + combo => { + string? message = build(combo.HasBetween, combo.Lo, combo.Hi, combo.Step, combo.Allow, combo.Excl); + if (message is null) { return true; } + + (int step, int[] allow, int[] excl) = InEffect(combo.HasBetween, combo.Lo, combo.Hi, combo.Step, combo.Allow, combo.Excl); + + return MessageIsTruthful(message, combo.HasBetween, combo.Lo, combo.Hi, step, allow, excl); + }) + .QuickCheckThrowOnFailure(); + } + + private static int[] Subset(int mask) { + List chosen = []; + for (int i = 0; i < Universe.Length; i++) { + if ((mask & (1 << i)) != 0) { chosen.Add(Universe[i]); } + } + + return chosen.ToArray(); + } + + /// The constraints actually in force at the throw: the prefix of the fixed build order up to the first one that empties the domain. + private static (int Step, int[] Allow, int[] Excl) InEffect(bool hasBetween, int lo, int hi, int step, int[] allow, int[] excl) { + int curStep = 1; + int[] curAllow = []; + int[] curExcl = []; + + if (Feasible(hasBetween, lo, hi, curStep, curAllow, curExcl).Count == 0) { return (curStep, curAllow, curExcl); } + if (step > 1) { curStep = step; if (Feasible(hasBetween, lo, hi, curStep, curAllow, curExcl).Count == 0) { return (curStep, curAllow, curExcl); } } + if (allow.Length > 0) { curAllow = allow; if (Feasible(hasBetween, lo, hi, curStep, curAllow, curExcl).Count == 0) { return (curStep, curAllow, curExcl); } } + if (excl.Length > 0) { curExcl = excl; } + + return (curStep, curAllow, curExcl); + } + + private static List Feasible(bool hasBetween, int lo, int hi, int step, int[] allow, int[] excl) { + int rangeLo = hasBetween ? lo : -50; + int rangeHi = hasBetween ? hi : 50; + + List feasible = []; + for (int value = rangeLo; value <= rangeHi; value++) { + if (allow.Length > 0 && !allow.Contains(value)) { continue; } + if (excl.Contains(value)) { continue; } + if (step > 1 && value % step != 0) { continue; } + feasible.Add(value); + } + + return feasible; + } + + /// The oracle: true when every checkable claim the message makes is backed by independently computed ground truth. + private static bool MessageIsTruthful(string message, bool hasBetween, int lo, int hi, int step, int[] allow, int[] excl) { + // A conflict was thrown, so the in-effect domain must genuinely be empty. + if (Feasible(hasBetween, lo, hi, step, allow, excl).Count != 0) { return false; } + + // Allow-list claims. The bare form asserts every allowed value is forbidden; the qualified form asserts only + // the values the bounds and lattice still permit are forbidden. + if (message.Contains("every value") && message.Contains("allows") && allow.Length > 0) { + int rangeLo = hasBetween ? lo : -50; + int rangeHi = hasBetween ? hi : 50; + int[] reachable = allow.Where(a => a >= rangeLo && a <= rangeHi && (step <= 1 || a % step == 0)).ToArray(); + + bool qualified = message.Contains("that the other constraints leave"); + bool claimTrue = qualified ? reachable.All(a => excl.Contains(a)) : allow.All(a => excl.Contains(a)); + if (!claimTrue) { return false; } + + // The bare form must not be used when a bound or the lattice already dropped an allowed value. + if (!qualified && reachable.Length != allow.Length) { return false; } + } + + // A range claim must hold for every value in the window. + if (hasBetween && message.Contains($"every value between {lo} and {hi}")) { + if (!Enumerable.Range(lo, hi - lo + 1).All(value => excl.Contains(value))) { return false; } + } + + // A lattice claim must hold for every on-lattice value in the window. + if (hasBetween && step > 1 && message.Contains($"every MultipleOf({step}) value between {lo} and {hi}")) { + if (!Enumerable.Range(lo, hi - lo + 1).Where(value => value % step == 0).All(value => excl.Contains(value))) { return false; } + } + + // Comprehensibility: no malformed fragment, and no "Cannot apply X because X forbids …" echo. + if (message.Contains(" ") || message.Contains(" ,") || message.Contains("forbids ,") || message.Contains("forbid ,")) { return false; } + int because = message.IndexOf(" because ", StringComparison.Ordinal); + if (because >= 0 && message.StartsWith("Cannot apply ", StringComparison.Ordinal)) { + int prefix = "Cannot apply ".Length; + string applied = message.Substring(prefix, because - prefix); + string clause = message.Substring(because + " because ".Length); + if (clause.StartsWith(applied + " forbids", StringComparison.Ordinal) || clause.StartsWith(applied + " forbid", StringComparison.Ordinal)) { return false; } + } + + return true; + } + + #endregion + + [Fact(DisplayName = "Ordinal: every exclusion-caused conflict message makes only true claims, over the whole combination space.")] + public void OrdinalConflictMessagesAreTruthful() { + CheckEngine(BuildInt32, supportsLattice: true); + } + + [Fact(DisplayName = "Decimal: every exclusion-caused conflict message makes only true claims, over the whole combination space.")] + public void DecimalConflictMessagesAreTruthful() { + CheckEngine(BuildDecimal, supportsLattice: false); + } + + [Fact(DisplayName = "Continuous: every exclusion-caused conflict message makes only true claims, over the whole combination space.")] + public void ContinuousConflictMessagesAreTruthful() { + CheckEngine(BuildDouble, supportsLattice: false); + } + + #region Engine builders + + private static string? BuildInt32(bool hasBetween, int lo, int hi, int step, int[] allow, int[] excl) { + try { + AnyInt32 spec = Any.Int32(); + if (hasBetween) { spec = spec.Between(lo, hi); } + if (step > 1) { spec = spec.MultipleOf(step); } + if (allow.Length > 0) { spec = spec.OneOf(allow); } + if (excl.Length > 0) { spec = spec.Except(excl); } + + return null; + } catch (ConflictingAnyConstraintException exception) { return exception.Message; } + } + + private static string? BuildDecimal(bool hasBetween, int lo, int hi, int step, int[] allow, int[] excl) { + try { + AnyDecimal spec = Any.Decimal(); + if (hasBetween) { spec = spec.Between(lo, hi); } + if (allow.Length > 0) { spec = spec.OneOf(allow.Select(value => (decimal)value).ToArray()); } + if (excl.Length > 0) { spec = spec.Except(excl.Select(value => (decimal)value).ToArray()); } + + return null; + } catch (ConflictingAnyConstraintException exception) { return exception.Message; } + } + + private static string? BuildDouble(bool hasBetween, int lo, int hi, int step, int[] allow, int[] excl) { + try { + AnyDouble spec = Any.Double(); + if (hasBetween) { spec = spec.Between(lo, hi); } + if (allow.Length > 0) { spec = spec.OneOf(allow.Select(value => (double)value).ToArray()); } + if (excl.Length > 0) { spec = spec.Except(excl.Select(value => (double)value).ToArray()); } + + return null; + } catch (ConflictingAnyConstraintException exception) { return exception.Message; } + } + + #endregion + +} diff --git a/JustDummies.PropertyTests/ModernTypeInvariantProperties.cs b/JustDummies.PropertyTests/ModernTypeInvariantProperties.cs index 1a24c20..cde6a82 100644 --- a/JustDummies.PropertyTests/ModernTypeInvariantProperties.cs +++ b/JustDummies.PropertyTests/ModernTypeInvariantProperties.cs @@ -176,6 +176,24 @@ public void Int128LessThanIsStrictAndConflictsAtTheFloor() { .QuickCheckThrowOnFailure(); } + [Fact(DisplayName = "Int128: every exclusion-caused conflict message makes only true claims, over the whole combination space.")] + public void Int128ConflictMessagesAreTruthful() { + // The 128-bit sibling of the ordinal engine; the shared oracle lives in ConflictMessageTruthfulnessProperties. + ConflictMessageTruthfulnessProperties.CheckEngine(BuildInt128, supportsLattice: true); + } + + private static string? BuildInt128(bool hasBetween, int lo, int hi, int step, int[] allow, int[] excl) { + try { + AnyInt128 spec = Any.Int128(); + if (hasBetween) { spec = spec.Between(lo, hi); } + if (step > 1) { spec = spec.MultipleOf(step); } + if (allow.Length > 0) { spec = spec.OneOf(allow.Select(value => (Int128)value).ToArray()); } + if (excl.Length > 0) { spec = spec.Except(excl.Select(value => (Int128)value).ToArray()); } + + return null; + } catch (ConflictingAnyConstraintException exception) { return exception.Message; } + } + [Fact(DisplayName = "Int128: Positive and Negative meet a bound on their own side of zero, and conflict with one on the other.")] public void Int128SignConstraintsMeetABoundOrConflict() { Prop.ForAll(Int128Values().ToArbitrary(), diff --git a/JustDummies.UnitTests/ConflictMessageProvenanceTests.cs b/JustDummies.UnitTests/ConflictMessageProvenanceTests.cs new file mode 100644 index 0000000..828aad9 --- /dev/null +++ b/JustDummies.UnitTests/ConflictMessageProvenanceTests.cs @@ -0,0 +1,150 @@ +#region Usings declarations + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace JustDummies.UnitTests; + +/// +/// A conflict message must name the constraint that actually caused the conflict. When an exclusion +/// (NonZero/Except/DifferentFrom) empties the domain, the interval engines used to name a +/// bound instead — or the constraint being applied — producing messages that were self-referential +/// ("Cannot apply Zero() because Zero() already pins the value to 0") or factually false +/// ("GreaterThanOrEqualTo(5) already pins the value to 5", which allows 5..MaxValue). Issue #312. +/// +/// +/// Message content is the example suite's job (ADR-0040): these pin the contract "name the excluding +/// constraint", not the exact prose. Each asserts that the offending exclusion appears in the message — the +/// information that was missing — which is red against the old engines and green once exclusions carry +/// provenance. The four interval engines (ordinal, wide, decimal, continuous) share one exhaustion path, so a +/// case per engine guards them all. +/// +[TestSubject(typeof(ConflictingAnyConstraintException))] +public sealed class ConflictMessageProvenanceTests { + + #region Statics members declarations + + private static string ConflictMessage(Action build) { + try { + build(); + } catch (ConflictingAnyConstraintException exception) { + return exception.Message; + } + + return ""; + } + + #endregion + + // ----- OrdinalIntervalSpec: integers (and, by sharing the engine, TimeSpan/DateTime/DateOnly/TimeOnly) ----- + + [Fact(DisplayName = "A pin emptied by NonZero names NonZero, not the pin itself.")] + public void PinEmptiedByNonZeroNamesTheExclusion() { + string message = ConflictMessage(() => Any.Byte().NonZero().Zero()); + + Check.WithCustomMessage($"The exclusion was not named. Message: {message}").That(message).Contains("NonZero()"); + } + + [Fact(DisplayName = "A single-value bound emptied by NonZero names NonZero.")] + public void BoundEmptiedByNonZeroNamesTheExclusion() { + string message = ConflictMessage(() => Any.Byte().NonZero().LessThan(1)); + + Check.WithCustomMessage($"The exclusion was not named. Message: {message}").That(message).Contains("NonZero()"); + } + + [Fact(DisplayName = "A range pinned by two bounds and emptied by Except names Except, not a bound.")] + public void PinEmptiedByExceptNamesTheExclusionNotABound() { + string message = ConflictMessage(() => Any.Int32().Except(5).GreaterThanOrEqualTo(5).LessThanOrEqualTo(5)); + + Check.WithCustomMessage($"The exclusion was not named. Message: {message}").That(message).Contains("Except(5)"); + } + + [Fact(DisplayName = "A pin emptied by DifferentFrom names DifferentFrom.")] + public void PinEmptiedByDifferentFromNamesTheExclusion() { + string message = ConflictMessage(() => Any.Int32().DifferentFrom(-1).Negative().GreaterThan(-2)); + + Check.WithCustomMessage($"The exclusion was not named. Message: {message}").That(message).Contains("DifferentFrom(-1)"); + } + + [Fact(DisplayName = "A lattice emptied by Except names Except and the lattice, not the lattice alone.")] + public void LatticeEmptiedByExceptNamesBothTheExclusionAndTheLattice() { + string message = ConflictMessage(() => Any.Int32().MultipleOf(5).Except(0).Between(-4, 4)); + + Check.WithCustomMessage($"The exclusion was not named. Message: {message}").That(message).Contains("Except(0)"); + Check.WithCustomMessage($"The lattice was not named. Message: {message}").That(message).Contains("MultipleOf(5)"); + } + + [Fact(DisplayName = "An allow-list emptied by Except names Except, not just the allow-list.")] + public void AllowListEmptiedByExceptNamesTheExclusion() { + string message = ConflictMessage(() => Any.Int32().Except(1, 2).OneOf(1, 2)); + + Check.WithCustomMessage($"The exclusion was not named. Message: {message}").That(message).Contains("Except(1, 2)"); + } + + // ----- DecimalIntervalSpec ----- + + [Fact(DisplayName = "A decimal pin emptied by DifferentFrom names DifferentFrom.")] + public void DecimalPinEmptiedByDifferentFromNamesTheExclusion() { + string message = ConflictMessage(() => Any.Decimal().DifferentFrom(1m).Between(1m, 1m)); + + Check.WithCustomMessage($"The exclusion was not named. Message: {message}").That(message).Contains("DifferentFrom(1)"); + } + + // ----- ContinuousIntervalSpec: double (and, by sharing the engine, Single/Half) ----- + + [Fact(DisplayName = "A double pin emptied by DifferentFrom names DifferentFrom.")] + public void DoublePinEmptiedByDifferentFromNamesTheExclusion() { + string message = ConflictMessage(() => Any.Double().DifferentFrom(1d).Between(1d, 1d)); + + Check.WithCustomMessage($"The exclusion was not named. Message: {message}").That(message).Contains("DifferentFrom(1)"); + } + +#if NET8_0_OR_GREATER + // ----- WideIntervalSpec: Int128/UInt128 (net8.0 leg only) ----- + + [Fact(DisplayName = "An Int128 pin emptied by NonZero names NonZero.")] + public void Int128PinEmptiedByNonZeroNamesTheExclusion() { + string message = ConflictMessage(() => Any.Int128().NonZero().Between(System.Int128.Zero, System.Int128.Zero)); + + Check.WithCustomMessage($"The exclusion was not named. Message: {message}").That(message).Contains("NonZero()"); + } +#endif + + // ----- Correctness of the claim, not only the naming (surfaced by the exhaustive audit). ----- + + [Fact(DisplayName = "An allow-list narrowed by a bound before an exclusion empties it does not claim the exclusion forbids every allowed value.")] + public void AllowListNarrowedByABoundIsNotOverclaimed() { + // OneOf(1, 3) offers two values, but Between(0, 1) already drops 3; Except(1) then removes the only one + // that survived. Saying Except(1) forbids *every* value OneOf allows would be false — it never forbids 3 — + // so the claim must be qualified to the values the other constraints leave. + string message = ConflictMessage(() => Any.Int32().Except(1).Between(0, 1).OneOf(1, 3)); + + Check.WithCustomMessage($"The exclusion was not named. Message: {message}").That(message).Contains("Except(1)"); + Check.WithCustomMessage($"The message overclaims that Except(1) forbids every value OneOf allows. Message: {message}") + .That(message).Contains("that the other constraints leave"); + } + + [Fact(DisplayName = "When the applied exclusion is itself the sole cause, the message reads 'it forbids', not the constraint twice.")] + public void AnExclusionAppliedLastIsNotRepeatedOnBothSides() { + // Zero() pins the byte to 0; NonZero(), applied last, is itself the forbidder. Repeating "NonZero()" on + // both sides of "because" reads as circular, so the clause refers back to the applied constraint as "it". + string message = ConflictMessage(() => Any.Byte().Zero().NonZero()); + + Check.WithCustomMessage($"The applied constraint should be referred to as 'it'. Message: {message}").That(message).Contains("it forbids"); + Check.WithCustomMessage($"The applied constraint is echoed after 'because'. Message: {message}").That(message).Not.Contains("because NonZero()"); + } + + // ----- Regression guard: bound-vs-bound messages must stay correct and unchanged. ----- + + [Fact(DisplayName = "A bound-vs-bound conflict still names the opposing bound (unchanged).")] + public void BoundVersusBoundStillNamesBothSides() { + string message = ConflictMessage(() => Any.Int32().Between(1, 10).GreaterThan(50)); + + Check.That(message).Contains("Between(1, 10)"); + Check.That(message).Contains("GreaterThan(50)"); + } + +} diff --git a/JustDummies/ContinuousIntervalSpec.cs b/JustDummies/ContinuousIntervalSpec.cs index c71c4a0..13a5104 100644 --- a/JustDummies/ContinuousIntervalSpec.cs +++ b/JustDummies/ContinuousIntervalSpec.cs @@ -57,6 +57,7 @@ internal static double NextDown(double value) { private readonly string? _allowedConstraint; private readonly List? _effectiveAllowed; private readonly IReadOnlyList _excluded; + private readonly IReadOnlyList<(string Constraint, double[] Ordinals)> _exclusions; private readonly Func _nextUp; private readonly double _max; private readonly string? _maxConstraint; @@ -72,7 +73,7 @@ private ContinuousIntervalSpec(string typeName, Func render, Fu double min, string? minConstraint, double max, string? maxConstraint, IReadOnlyList? allowed, string? allowedConstraint, - IReadOnlyList excluded) { + IReadOnlyList<(string Constraint, double[] Ordinals)> exclusions) { _typeName = typeName; _render = render; _quantize = quantize; @@ -83,8 +84,10 @@ private ContinuousIntervalSpec(string typeName, Func render, Fu _maxConstraint = maxConstraint; _allowed = allowed; _allowedConstraint = allowedConstraint; - _excluded = excluded; - // Materialized once here — "constrain once, draw many": Generate never refilters the allow-list. + _exclusions = exclusions; + // The flat value set drives every draw-time decision; the provenance in _exclusions is consulted only + // when a conflict message must name the excluding constraint. Materialized once — "constrain once, draw many". + _excluded = exclusions.SelectMany(pair => pair.Ordinals).ToList(); _effectiveAllowed = allowed?.Where(value => value >= min && value <= max && !IsExcluded(value)).ToList(); } @@ -99,7 +102,7 @@ internal ContinuousIntervalSpec WithMinimum(double minimum, string applying) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_maxConstraint} already requires values less than or equal to {_render(_max)}."); } - return Validated(new ContinuousIntervalSpec(_typeName, _render, _quantize, _nextUp, minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _excluded), applying); + return Validated(new ContinuousIntervalSpec(_typeName, _render, _quantize, _nextUp, minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _exclusions), applying); } /// Tightens the upper bound; a looser bound than the current one is a no-op. @@ -113,7 +116,7 @@ internal ContinuousIntervalSpec WithMaximum(double maximum, string applying) { throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {_minConstraint} already requires values greater than or equal to {_render(_min)}."); } - return Validated(new ContinuousIntervalSpec(_typeName, _render, _quantize, _nextUp, _min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _excluded), applying); + return Validated(new ContinuousIntervalSpec(_typeName, _render, _quantize, _nextUp, _min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _exclusions), applying); } /// Tightens the lower bound to strictly above — via the type's next representable value. @@ -132,15 +135,16 @@ internal ContinuousIntervalSpec WithAllowed(double[] values, string applying) { double[] distinct = values.Distinct().ToArray(); - return Validated(new ContinuousIntervalSpec(_typeName, _render, _quantize, _nextUp, _min, _minConstraint, _max, _maxConstraint, distinct, applying, _excluded), applying); + return Validated(new ContinuousIntervalSpec(_typeName, _render, _quantize, _nextUp, _min, _minConstraint, _max, _maxConstraint, distinct, applying, _exclusions), applying); } /// Adds values the generator must never produce. internal ContinuousIntervalSpec WithExcluded(double[] values, string applying) { - List excluded = new(_excluded); - excluded.AddRange(values); + // The applied constraint tags its own values, so a later exhaustion message can name the exclusion + // that actually emptied the domain rather than a bound that merely happens to border it. + List<(string Constraint, double[] Ordinals)> exclusions = new(_exclusions) { (applying, values) }; - return Validated(new ContinuousIntervalSpec(_typeName, _render, _quantize, _nextUp, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded), applying); + return Validated(new ContinuousIntervalSpec(_typeName, _render, _quantize, _nextUp, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, exclusions), applying); } /// @@ -240,7 +244,7 @@ private bool IsExcluded(double value) { private ContinuousIntervalSpec Validated(ContinuousIntervalSpec candidate, string applying) { if (candidate.IsSatisfiable()) { return candidate; } - throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {candidate.DescribeExhaustion()}."); + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {candidate.DescribeExhaustion(applying)}."); } private bool IsSatisfiable() { @@ -250,18 +254,74 @@ private bool IsSatisfiable() { return !IsExcluded(_min); } - private string DescribeExhaustion() { + private string DescribeExhaustion(string applying) { + IReadOnlyList culprits = ExcludingConstraintsInEffect(); + if (_allowed is not null) { - if (_excluded.Count > 0) { - return $"no value {_allowedConstraint} allows remains available"; - } + if (culprits.Count == 0) { return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; } + + // Only the allow-list values the bounds still permit can be forbidden by an exclusion; if some allowed + // value was already dropped by a bound, the exclusions do not forbid "every" allowed value, so the claim + // is qualified rather than overstated. + string allowed = _allowed.All(WouldAllowIgnoringExclusions) + ? $"every value {_allowedConstraint} allows" + : $"every value {_allowedConstraint} allows that the other constraints leave"; + + return $"{Forbids(culprits, applying)} {allowed}"; + } + + if (culprits.Count == 0) { + string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; - return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; + return $"{pinning} already pins the value to {_render(_min)}, which the exclusions forbid"; + } + + return $"{Forbids(culprits, applying)} {_render(_min)}, {PinningClause()}"; + } + + /// + /// The distinct exclusion constraints that actually caused the exhaustion — those forbidding at least one + /// value the interval and allow-list would otherwise permit. An exclusion whose values fall outside the + /// surviving domain never bit, so naming it would mislead; first-declared order is preserved. + /// + private IReadOnlyList ExcludingConstraintsInEffect() { + List names = new(); + foreach ((string constraint, double[] values) in _exclusions) { + if (names.Contains(constraint)) { continue; } + if (values.Any(WouldAllowIgnoringExclusions)) { names.Add(constraint); } } - string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; + return names; + } + + /// Whether would be in the domain if no exclusion were applied. + private bool WouldAllowIgnoringExclusions(double value) { + if (_allowed is not null && !_allowed.Contains(value)) { return false; } + + return value >= _min && value <= _max; + } + + /// + /// The subject of the exhaustion clause. A single culprit that is the constraint being applied becomes "it", + /// so the message reads "Cannot apply DifferentFrom(1) because it forbids …" rather than repeating the + /// constraint on both sides of "because". + /// + private static string Forbids(IReadOnlyList names, string applying) { + if (names.Count == 1) { return names[0] == applying ? "it forbids" : $"{names[0]} forbids"; } + + return $"{string.Join(", ", names)} forbid"; + } + + /// Names the bounds that pinned the domain to its single value, for the "forbids X, the only value ... leaves" form. + private string PinningClause() { + List bounds = new(); + if (_minConstraint is not null) { bounds.Add(_minConstraint); } + if (_maxConstraint is not null && _maxConstraint != _minConstraint) { bounds.Add(_maxConstraint); } + + if (bounds.Count == 0) { return "the only value the declared bounds leave"; } + if (bounds.Count == 1) { return $"the only value {bounds[0]} leaves"; } - return $"{pinning} already pins the value to {_render(_min)}, which the exclusions forbid"; + return $"the only value {string.Join(" and ", bounds)} leave"; } } diff --git a/JustDummies/DecimalIntervalSpec.cs b/JustDummies/DecimalIntervalSpec.cs index c3494e2..a528e09 100644 --- a/JustDummies/DecimalIntervalSpec.cs +++ b/JustDummies/DecimalIntervalSpec.cs @@ -40,6 +40,7 @@ private static decimal Pow10(int power) { private readonly decimal _ceiledMin; private readonly List? _effectiveAllowed; private readonly IReadOnlyList _excluded; + private readonly IReadOnlyList<(string Constraint, decimal[] Ordinals)> _exclusions; private readonly int _excludedOnLattice; private readonly decimal _flooredMax; private readonly bool _latticeHasPoint; @@ -59,7 +60,7 @@ private DecimalIntervalSpec(string typeName, Func render, decimal min, string? minConstraint, decimal max, string? maxConstraint, IReadOnlyList? allowed, string? allowedConstraint, - IReadOnlyList excluded, + IReadOnlyList<(string Constraint, decimal[] Ordinals)> exclusions, int scale, string? scaleConstraint) { _typeName = typeName; _render = render; @@ -69,16 +70,19 @@ private DecimalIntervalSpec(string typeName, Func render, _maxConstraint = maxConstraint; _allowed = allowed; _allowedConstraint = allowedConstraint; - _excluded = excluded; + _exclusions = exclusions; _scale = scale; _scaleConstraint = scaleConstraint; + // The flat value set drives every draw-time decision; the provenance in _exclusions is consulted only + // when a conflict message must name the excluding constraint. Materialized once — "constrain once, draw many". + _excluded = exclusions.SelectMany(pair => pair.Ordinals).ToList(); // 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)); + _excludedOnLattice = _excluded.Count(value => value >= min && value <= max && IsOnGrid(value, scale)); } else { _step = 0m; _ceiledMin = min; @@ -100,7 +104,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, _scale, _scaleConstraint), applying); + return Validated(new DecimalIntervalSpec(_typeName, _render, minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _exclusions, _scale, _scaleConstraint), applying); } /// Tightens the upper bound; a looser bound than the current one is a no-op. @@ -113,7 +117,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, _scale, _scaleConstraint), applying); + return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _exclusions, _scale, _scaleConstraint), applying); } /// Tightens the lower bound to strictly above — the inclusive bound plus a point exclusion. @@ -132,15 +136,16 @@ 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, _scale, _scaleConstraint), applying); + return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, _max, _maxConstraint, distinct, applying, _exclusions, _scale, _scaleConstraint), applying); } /// Adds values the generator must never produce. internal DecimalIntervalSpec WithExcluded(decimal[] values, string applying) { - List excluded = new(_excluded); - excluded.AddRange(values); + // The applied constraint tags its own values, so a later exhaustion message can name the exclusion + // that actually emptied the domain rather than a bound that merely happens to border it. + List<(string Constraint, decimal[] Ordinals)> exclusions = new(_exclusions) { (applying, values) }; - return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded, _scale, _scaleConstraint), applying); + return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, exclusions, _scale, _scaleConstraint), applying); } /// @@ -155,7 +160,7 @@ internal DecimalIntervalSpec WithScale(int scale, string applying) { 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); + return Validated(new DecimalIntervalSpec(_typeName, _render, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, _exclusions, scale, applying), applying); } /// @@ -324,7 +329,7 @@ private bool IsExcluded(decimal value) { private DecimalIntervalSpec Validated(DecimalIntervalSpec candidate, string applying) { if (candidate.IsSatisfiable()) { return candidate; } - throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {candidate.DescribeExhaustion()}."); + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {candidate.DescribeExhaustion(applying)}."); } private bool IsSatisfiable() { @@ -341,22 +346,81 @@ private bool IsSatisfiable() { return !IsExcluded(_min); } - private string DescribeExhaustion() { + private string DescribeExhaustion(string applying) { + IReadOnlyList culprits = ExcludingConstraintsInEffect(); + if (_allowed is not null) { - if (_excluded.Count > 0) { - return $"no value {_allowedConstraint} allows remains available"; - } + if (culprits.Count == 0) { return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; } - return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; + // Only the allow-list values the bounds and scale lattice still permit can be forbidden by an exclusion; + // if some allowed value was already dropped by a bound or the grid, the exclusions do not forbid "every" + // allowed value, so the claim is qualified rather than overstated. + string allowed = _allowed.All(WouldAllowIgnoringExclusions) + ? $"every value {_allowedConstraint} allows" + : $"every value {_allowedConstraint} allows that the other constraints leave"; + + return $"{Forbids(culprits, applying)} {allowed}"; } if (_scale >= 0) { - return $"no {_typeName} value {_scaleConstraint} allows remains between {_render(_min)} and {_render(_max)}"; + if (!_latticeHasPoint || culprits.Count == 0) { return $"no {_typeName} value {_scaleConstraint} allows remains between {_render(_min)} and {_render(_max)}"; } + + return $"{Forbids(culprits, applying)} every {_scaleConstraint} value between {_render(_min)} and {_render(_max)}"; + } + + if (culprits.Count == 0) { + string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; + + return $"{pinning} already pins the value to {_render(_min)}, which the exclusions forbid"; + } + + return $"{Forbids(culprits, applying)} {_render(_min)}, {PinningClause()}"; + } + + /// + /// The distinct exclusion constraints that actually caused the exhaustion — those forbidding at least one + /// value the interval, scale lattice and allow-list would otherwise permit. An exclusion whose values fall + /// outside the surviving domain never bit, so naming it would mislead; first-declared order is preserved. + /// + private IReadOnlyList ExcludingConstraintsInEffect() { + List names = new(); + foreach ((string constraint, decimal[] values) in _exclusions) { + if (names.Contains(constraint)) { continue; } + if (values.Any(WouldAllowIgnoringExclusions)) { names.Add(constraint); } } - string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; + return names; + } + + /// Whether would be in the domain if no exclusion were applied. + private bool WouldAllowIgnoringExclusions(decimal value) { + if (_allowed is not null && !_allowed.Contains(value)) { return false; } + if (_scale >= 0 && !IsOnGrid(value, _scale)) { return false; } + + return value >= _min && value <= _max; + } + + /// + /// The subject of the exhaustion clause. A single culprit that is the constraint being applied becomes "it", + /// so the message reads "Cannot apply DifferentFrom(1) because it forbids …" rather than repeating the + /// constraint on both sides of "because". + /// + private static string Forbids(IReadOnlyList names, string applying) { + if (names.Count == 1) { return names[0] == applying ? "it forbids" : $"{names[0]} forbids"; } + + return $"{string.Join(", ", names)} forbid"; + } + + /// Names the bounds that pinned the domain to its single value, for the "forbids X, the only value ... leaves" form. + private string PinningClause() { + List bounds = new(); + if (_minConstraint is not null) { bounds.Add(_minConstraint); } + if (_maxConstraint is not null && _maxConstraint != _minConstraint) { bounds.Add(_maxConstraint); } + + if (bounds.Count == 0) { return "the only value the declared bounds leave"; } + if (bounds.Count == 1) { return $"the only value {bounds[0]} leaves"; } - return $"{pinning} already pins the value to {_render(_min)}, which the exclusions forbid"; + return $"the only value {string.Join(" and ", bounds)} leave"; } } diff --git a/JustDummies/OrdinalIntervalSpec.cs b/JustDummies/OrdinalIntervalSpec.cs index a1ac575..4a6e1df 100644 --- a/JustDummies/OrdinalIntervalSpec.cs +++ b/JustDummies/OrdinalIntervalSpec.cs @@ -91,7 +91,7 @@ private static bool TryFirstLatticePoint(ulong min, ulong max, ulong anchor, ulo private readonly ulong _domainMax; private readonly ulong _domainMin; private readonly List? _effectiveAllowed; - private readonly IReadOnlyList _excluded; + private readonly IReadOnlyList<(string Constraint, ulong[] Ordinals)> _exclusions; private readonly List _excludedInRange; private readonly List _excludedOnLattice; private readonly ulong _latticeFirst; @@ -111,7 +111,7 @@ private OrdinalIntervalSpec(string typeName, Func render, ulong d ulong min, string? minConstraint, ulong max, string? maxConstraint, IReadOnlyList? allowed, string? allowedConstraint, - IReadOnlyList excluded, + IReadOnlyList<(string Constraint, ulong[] Ordinals)> exclusions, ulong step, ulong anchor, string? stepConstraint) { _typeName = typeName; _render = render; @@ -123,11 +123,14 @@ private OrdinalIntervalSpec(string typeName, Func render, ulong d _maxConstraint = maxConstraint; _allowed = allowed; _allowedConstraint = allowedConstraint; - _excluded = excluded; + _exclusions = exclusions; _step = step; _anchor = anchor; _stepConstraint = stepConstraint; - // Materialized once here — "constrain once, draw many": GenerateOrdinal never refilters or resorts. + // The flat ordinal set drives every hot-path decision; the provenance in _exclusions is consulted only + // when a conflict message must name the excluding constraint. Materialized once here — "constrain once, + // draw many": GenerateOrdinal never refilters or resorts. + ulong[] excluded = exclusions.SelectMany(pair => pair.Ordinals).ToArray(); _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. @@ -156,7 +159,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, _step, _anchor, _stepConstraint), applying); + return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _exclusions, _step, _anchor, _stepConstraint), applying); } /// Tightens the lower bound to strictly above — the exclusive form of . @@ -176,7 +179,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, _step, _anchor, _stepConstraint), applying); + return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _exclusions, _step, _anchor, _stepConstraint), applying); } /// Tightens the upper bound to strictly below — the exclusive form of . @@ -192,15 +195,16 @@ 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, _step, _anchor, _stepConstraint), applying); + return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, distinct, applying, _exclusions, _step, _anchor, _stepConstraint), applying); } /// Adds values the generator must never produce. internal OrdinalIntervalSpec WithExcluded(ulong[] ordinals, string applying) { - List excluded = new(_excluded); - excluded.AddRange(ordinals); + // The applied constraint tags its own ordinals, so a later exhaustion message can name the exclusion + // that actually emptied the domain rather than a bound that merely happens to border it. + List<(string Constraint, ulong[] Ordinals)> exclusions = new(_exclusions) { (applying, ordinals) }; - return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded, _step, _anchor, _stepConstraint), applying); + return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, exclusions, _step, _anchor, _stepConstraint), applying); } /// @@ -217,7 +221,7 @@ internal OrdinalIntervalSpec WithStep(ulong step, ulong anchor, string applying) 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); + return Validated(new OrdinalIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, _exclusions, step, anchor, applying), applying); } /// @@ -305,7 +309,7 @@ private bool IsFullWidth() { private OrdinalIntervalSpec Validated(OrdinalIntervalSpec candidate, string applying) { if (candidate.IsSatisfiable()) { return candidate; } - throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {candidate.DescribeExhaustion()}."); + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {candidate.DescribeExhaustion(applying)}."); } private bool IsSatisfiable() { @@ -320,26 +324,87 @@ private bool IsSatisfiable() { return _max - _min + 1 - (ulong)_excludedInRange.Count > 0; } - private string DescribeExhaustion() { + private string DescribeExhaustion(string applying) { + IReadOnlyList culprits = ExcludingConstraintsInEffect(); + if (_allowed is not null) { - if (_excluded.Count > 0) { - return $"no value {_allowedConstraint} allows remains available"; - } + if (culprits.Count == 0) { return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; } - return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; + // Only the allow-list values the bounds and lattice still permit can be forbidden by an exclusion; if + // some allowed value was already dropped by a bound or the lattice, the exclusions do not forbid + // "every" allowed value, so the claim is qualified rather than overstated. + string allowed = _allowed.All(WouldAllowIgnoringExclusions) + ? $"every value {_allowedConstraint} allows" + : $"every value {_allowedConstraint} allows that the other constraints leave"; + + return $"{Forbids(culprits, applying)} {allowed}"; } if (_step > 1UL) { - return $"no {_typeName} value {_stepConstraint} allows remains between {_render(_min)} and {_render(_max)}"; + if (!_latticeHasPoint || culprits.Count == 0) { return $"no {_typeName} value {_stepConstraint} allows remains between {_render(_min)} and {_render(_max)}"; } + + return $"{Forbids(culprits, applying)} every {_stepConstraint} value between {_render(_min)} and {_render(_max)}"; } if (_min == _max) { - string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; + if (culprits.Count == 0) { + string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; - return $"{pinning} already pins the value to {_render(_min)}"; + return $"{pinning} already pins the value to {_render(_min)}"; + } + + return $"{Forbids(culprits, applying)} {_render(_min)}, {PinningClause()}"; + } + + if (culprits.Count == 0) { return $"no value remains between {_render(_min)} and {_render(_max)} once the excluded values are removed"; } + + return $"{Forbids(culprits, applying)} every value between {_render(_min)} and {_render(_max)}"; + } + + /// + /// The distinct exclusion constraints that actually caused the exhaustion — those forbidding at least one + /// value the interval, lattice and allow-list would otherwise permit. An exclusion whose values fall outside + /// the surviving domain never bit, so naming it would mislead; first-declared order is preserved. + /// + private IReadOnlyList ExcludingConstraintsInEffect() { + List names = new(); + foreach ((string constraint, ulong[] ordinals) in _exclusions) { + if (names.Contains(constraint)) { continue; } + if (ordinals.Any(WouldAllowIgnoringExclusions)) { names.Add(constraint); } } - return $"no value remains between {_render(_min)} and {_render(_max)} once the excluded values are removed"; + return names; + } + + /// Whether would be in the domain if no exclusion were applied. + private bool WouldAllowIgnoringExclusions(ulong ordinal) { + if (_allowed is not null && !_allowed.Contains(ordinal)) { return false; } + if (_step > 1UL && !IsOnLattice(ordinal, _anchor, _step)) { return false; } + + return ordinal >= _min && ordinal <= _max; + } + + /// + /// The subject of the exhaustion clause. A single culprit that is the constraint being applied becomes "it", + /// so the message reads "Cannot apply Except(1) because it forbids …" rather than repeating the constraint on + /// both sides of "because". + /// + private static string Forbids(IReadOnlyList names, string applying) { + if (names.Count == 1) { return names[0] == applying ? "it forbids" : $"{names[0]} forbids"; } + + return $"{string.Join(", ", names)} forbid"; + } + + /// Names the bounds that pinned the domain to its single value, for the "forbids X, the only value ... leaves" form. + private string PinningClause() { + List bounds = new(); + if (_minConstraint is not null) { bounds.Add(_minConstraint); } + if (_maxConstraint is not null && _maxConstraint != _minConstraint) { bounds.Add(_maxConstraint); } + + if (bounds.Count == 0) { return "the only value the declared bounds leave"; } + if (bounds.Count == 1) { return $"the only value {bounds[0]} leaves"; } + + return $"the only value {string.Join(" and ", bounds)} leave"; } } diff --git a/JustDummies/WideIntervalSpec.cs b/JustDummies/WideIntervalSpec.cs index e3bed2f..7de29fd 100644 --- a/JustDummies/WideIntervalSpec.cs +++ b/JustDummies/WideIntervalSpec.cs @@ -58,7 +58,7 @@ private static bool TryFirstLatticePoint(UInt128 min, UInt128 max, UInt128 ancho private readonly List _excludedInRange; private readonly List _excludedOnLattice; private readonly UInt128 _domainMin; - private readonly IReadOnlyList _excluded; + private readonly IReadOnlyList<(string Constraint, UInt128[] Ordinals)> _exclusions; private readonly UInt128 _latticeFirst; private readonly bool _latticeHasPoint; private readonly UInt128 _max; @@ -76,7 +76,7 @@ private WideIntervalSpec(string typeName, Func render, UInt128 UInt128 min, string? minConstraint, UInt128 max, string? maxConstraint, IReadOnlyList? allowed, string? allowedConstraint, - IReadOnlyList excluded, + IReadOnlyList<(string Constraint, UInt128[] Ordinals)> exclusions, UInt128 step, UInt128 anchor, string? stepConstraint) { _typeName = typeName; _render = render; @@ -88,11 +88,14 @@ private WideIntervalSpec(string typeName, Func render, UInt128 _maxConstraint = maxConstraint; _allowed = allowed; _allowedConstraint = allowedConstraint; - _excluded = excluded; + _exclusions = exclusions; _step = step; _anchor = anchor; _stepConstraint = stepConstraint; - // Materialized once here — "constrain once, draw many": GenerateOrdinal never refilters or resorts. + // The flat ordinal set drives every hot-path decision; the provenance in _exclusions is consulted only + // when a conflict message must name the excluding constraint. Materialized once here — "constrain once, + // draw many": GenerateOrdinal never refilters or resorts. + UInt128[] excluded = exclusions.SelectMany(pair => pair.Ordinals).ToArray(); _excludedInRange = excluded.Where(value => value >= min && value <= max).Distinct().ToList(); _excludedInRange.Sort(); if (step > UInt128.One) { @@ -120,7 +123,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, _step, _anchor, _stepConstraint), applying); + return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, minimum, applying, _max, _maxConstraint, _allowed, _allowedConstraint, _exclusions, _step, _anchor, _stepConstraint), applying); } /// Tightens the lower bound to strictly above — the exclusive form of . @@ -140,7 +143,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, _step, _anchor, _stepConstraint), applying); + return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, maximum, applying, _allowed, _allowedConstraint, _exclusions, _step, _anchor, _stepConstraint), applying); } /// Tightens the upper bound to strictly below — the exclusive form of . @@ -156,15 +159,16 @@ 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, _step, _anchor, _stepConstraint), applying); + return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, distinct, applying, _exclusions, _step, _anchor, _stepConstraint), applying); } /// Adds values the generator must never produce. internal WideIntervalSpec WithExcluded(UInt128[] ordinals, string applying) { - List excluded = new(_excluded); - excluded.AddRange(ordinals); + // The applied constraint tags its own ordinals, so a later exhaustion message can name the exclusion + // that actually emptied the domain rather than a bound that merely happens to border it. + List<(string Constraint, UInt128[] Ordinals)> exclusions = new(_exclusions) { (applying, ordinals) }; - return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, excluded, _step, _anchor, _stepConstraint), applying); + return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, exclusions, _step, _anchor, _stepConstraint), applying); } /// @@ -181,7 +185,7 @@ internal WideIntervalSpec WithStep(UInt128 step, UInt128 anchor, string applying 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); + return Validated(new WideIntervalSpec(_typeName, _render, _domainMin, _domainMax, _min, _minConstraint, _max, _maxConstraint, _allowed, _allowedConstraint, _exclusions, step, anchor, applying), applying); } /// @@ -265,7 +269,7 @@ private bool IsFullWidth() { private WideIntervalSpec Validated(WideIntervalSpec candidate, string applying) { if (candidate.IsSatisfiable()) { return candidate; } - throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {candidate.DescribeExhaustion()}."); + throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {candidate.DescribeExhaustion(applying)}."); } private bool IsSatisfiable() { @@ -280,26 +284,87 @@ private bool IsSatisfiable() { return _max - _min + 1 - (UInt128)_excludedInRange.Count > 0; } - private string DescribeExhaustion() { + private string DescribeExhaustion(string applying) { + IReadOnlyList culprits = ExcludingConstraintsInEffect(); + if (_allowed is not null) { - if (_excluded.Count > 0) { - return $"no value {_allowedConstraint} allows remains available"; - } + if (culprits.Count == 0) { return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; } - return $"none of the values {_allowedConstraint} allows satisfies the constraints already defined"; + // Only the allow-list values the bounds and lattice still permit can be forbidden by an exclusion; if + // some allowed value was already dropped by a bound or the lattice, the exclusions do not forbid + // "every" allowed value, so the claim is qualified rather than overstated. + string allowed = _allowed.All(WouldAllowIgnoringExclusions) + ? $"every value {_allowedConstraint} allows" + : $"every value {_allowedConstraint} allows that the other constraints leave"; + + return $"{Forbids(culprits, applying)} {allowed}"; } if (_step > UInt128.One) { - return $"no {_typeName} value {_stepConstraint} allows remains between {_render(_min)} and {_render(_max)}"; + if (!_latticeHasPoint || culprits.Count == 0) { return $"no {_typeName} value {_stepConstraint} allows remains between {_render(_min)} and {_render(_max)}"; } + + return $"{Forbids(culprits, applying)} every {_stepConstraint} value between {_render(_min)} and {_render(_max)}"; } if (_min == _max) { - string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; + if (culprits.Count == 0) { + string pinning = _minConstraint ?? _maxConstraint ?? "the declared bounds"; - return $"{pinning} already pins the value to {_render(_min)}"; + return $"{pinning} already pins the value to {_render(_min)}"; + } + + return $"{Forbids(culprits, applying)} {_render(_min)}, {PinningClause()}"; + } + + if (culprits.Count == 0) { return $"no value remains between {_render(_min)} and {_render(_max)} once the excluded values are removed"; } + + return $"{Forbids(culprits, applying)} every value between {_render(_min)} and {_render(_max)}"; + } + + /// + /// The distinct exclusion constraints that actually caused the exhaustion — those forbidding at least one + /// value the interval, lattice and allow-list would otherwise permit. An exclusion whose values fall outside + /// the surviving domain never bit, so naming it would mislead; first-declared order is preserved. + /// + private IReadOnlyList ExcludingConstraintsInEffect() { + List names = new(); + foreach ((string constraint, UInt128[] ordinals) in _exclusions) { + if (names.Contains(constraint)) { continue; } + if (ordinals.Any(WouldAllowIgnoringExclusions)) { names.Add(constraint); } } - return $"no value remains between {_render(_min)} and {_render(_max)} once the excluded values are removed"; + return names; + } + + /// Whether would be in the domain if no exclusion were applied. + private bool WouldAllowIgnoringExclusions(UInt128 ordinal) { + if (_allowed is not null && !_allowed.Contains(ordinal)) { return false; } + if (_step > UInt128.One && !IsOnLattice(ordinal, _anchor, _step)) { return false; } + + return ordinal >= _min && ordinal <= _max; + } + + /// + /// The subject of the exhaustion clause. A single culprit that is the constraint being applied becomes "it", + /// so the message reads "Cannot apply Except(1) because it forbids …" rather than repeating the constraint on + /// both sides of "because". + /// + private static string Forbids(IReadOnlyList names, string applying) { + if (names.Count == 1) { return names[0] == applying ? "it forbids" : $"{names[0]} forbids"; } + + return $"{string.Join(", ", names)} forbid"; + } + + /// Names the bounds that pinned the domain to its single value, for the "forbids X, the only value ... leaves" form. + private string PinningClause() { + List bounds = new(); + if (_minConstraint is not null) { bounds.Add(_minConstraint); } + if (_maxConstraint is not null && _maxConstraint != _minConstraint) { bounds.Add(_maxConstraint); } + + if (bounds.Count == 0) { return "the only value the declared bounds leave"; } + if (bounds.Count == 1) { return $"the only value {bounds[0]} leaves"; } + + return $"the only value {string.Join(" and ", bounds)} leave"; } }