Problem
Eager conflict detection is the library's flagship diagnostic: an impossible Arrange throws a ConflictingAnyConstraintException "whose message names both sides" (OrdinalIntervalSpec.cs:30-31; ADR-0035). That promise holds for bound-vs-bound conflicts but breaks whenever an exclusion (NonZero/Except/DifferentFrom) is the real cause: the message is then self-referential or factually false.
Measured (verified):
Any.Byte().NonZero().Zero()
// → "Cannot apply Zero() because Zero() already pins the value to 0." self-referential; NonZero() never named
Any.Byte().NonZero().LessThan(1)
// → "Cannot apply LessThan(1) because LessThan(1) already pins the value to 0." pure self-reference
Any.Int32().Except(5).GreaterThanOrEqualTo(5).LessThanOrEqualTo(5)
// → "... because GreaterThanOrEqualTo(5) already pins the value to 5." FALSE: GTE(5) allows 5..int.MaxValue
Any.Int32().DifferentFrom(-1).Negative().GreaterThan(-2)
// → "... because GreaterThan(-2) already pins the value to -1." self-reference + false
Any.Int32().MultipleOf(5).Except(0).Between(-4, 4)
// → "... because no Int32 value MultipleOf(5) allows remains between -4 and 4." FALSE: MultipleOf(5).Between(-4,4) yields 0; Except(0) is the cause
Any.Int32().Except(1, 2).OneOf(1, 2)
// → "... because no value OneOf(1, 2) allows remains available." self-reference
For contrast, bound-vs-bound works perfectly — Any.Int32().Between(1,10).GreaterThan(50) → "Cannot apply GreaterThan(50) because Between(1, 10) already requires values less than or equal to 10."
Root cause
An asymmetry in the interval engines. Bounds are first-class — each remembers the constraint that set it (_minConstraint, _maxConstraint, _stepConstraint, _allowedConstraint). Exclusions are second-class: _excluded is a bare list of values with no provenance (OrdinalIntervalSpec.cs:94). So when the feasible set empties because of an exclusion, DescribeExhaustion (:323-342) has no label to name and falls back to pinning = _minConstraint ?? _maxConstraint — naming a bound (or the constraint currently being applied), which is at best incomplete and at worst provably false. This is not a naive sampling model — the feasibility/sampling engine is sound; it is specifically the explanation layer that lacks the information and guesses.
A second, deeper point: the "name both sides" framing assumes a binary conflict, but the algebra produces n-ary ones (>=5 ∧ <=5 ∧ Except(5) is a three-way conflict). The fix must name the real participants without over-attributing a "pin" to a single bound.
Scope
The identical DescribeExhaustion shape lives in four interval engines, all in scope for this fix:
OrdinalIntervalSpec — every integer, TimeSpan, DateTime, DateOnly, TimeOnly
WideIntervalSpec — Int128, UInt128 (exact clone)
DecimalIntervalSpec — decimal (:359, same defect with a generic "which the exclusions forbid" suffix)
ContinuousIntervalSpec — double, float, Half (:264, same)
Out of this PR, flagged as the same defect in bespoke code: AnyEnum (:268) and AnyGuid (:178) carry the same self-referential "no value OneOf(...) allows remains available" when an exclusion empties their allow-list. Recommended as a tight follow-up unless folded in here.
Impact
The one feature the library advertises as distinctive — a conflict that "reads as the test defect it is" — actively misleads for every exclusion-caused conflict, on a five-call chain sending the reader to the wrong constraint. Message text becomes a de-facto contract at 1.0.0 (consumers regex-match it), so freezing it wrong freezes it for good, and the structural fix gets more expensive once the pattern is relied upon.
Direction
Option 2 from the design discussion: give exclusions the same provenance bounds already carry, then rewrite DescribeExhaustion to reason about the real emptiness structure and attribute correctly — never self-referential, never false, always naming the excluding constraint(s) plus the restriction they emptied. No general constraint solver: the feasibility engine is unchanged; only the diagnostic layer gains information and correctness.
Target messages (wording to be confirmed):
NonZero().Zero() → Cannot apply Zero() because NonZero() forbids 0, the only value Zero() allows.
NonZero().LessThan(1) → Cannot apply LessThan(1) because NonZero() forbids 0, the only value LessThan(1) leaves.
Except(5).GreaterThanOrEqualTo(5).LessThanOrEqualTo(5)
→ Cannot apply LessThanOrEqualTo(5) because Except(5) forbids 5, the only value the declared bounds leave.
DifferentFrom(-1).Negative().GreaterThan(-2)
→ Cannot apply GreaterThan(-2) because DifferentFrom(-1) forbids -1, the only value the declared bounds leave.
MultipleOf(5).Except(0).Between(-4,4) → Cannot apply Between(-4, 4) because Except(0) forbids 0, the only MultipleOf(5) value in [-4, 4].
Except(1,2).OneOf(1,2) → Cannot apply OneOf(1, 2) because Except(1, 2) forbids every value OneOf(1, 2) allows.
Acceptance criteria
- Every exclusion-caused conflict names the excluding constraint; no message is self-referential or states a falsehood; bound-vs-bound messages are unchanged.
- Example tests in
JustDummies.UnitTests pin the message content for each shape (message content is the example suite's job per ADR-0040 / WritingJustDummiesTests), across all four interval engines.
- No behavioural change to generation, feasibility, or the public API surface; the existing suites stay green; build stays at 0 warnings.
- Consistency check against ADR-0035: this realizes its "names both sides" promise for the exclusion case rather than changing a decision, so no new ADR is expected — to be confirmed during the change.
Context
Surfaced by the 2026-07 JustDummies v1.0.0 readiness audit as problem #2 (MUST FIX): the library's distinctive feature provably mis-reports the cause of a conflict. Sibling of the concurrency fix in #310 / PR #311.
Problem
Eager conflict detection is the library's flagship diagnostic: an impossible
Arrangethrows aConflictingAnyConstraintException"whose message names both sides" (OrdinalIntervalSpec.cs:30-31; ADR-0035). That promise holds for bound-vs-bound conflicts but breaks whenever an exclusion (NonZero/Except/DifferentFrom) is the real cause: the message is then self-referential or factually false.Measured (verified):
For contrast, bound-vs-bound works perfectly —
Any.Int32().Between(1,10).GreaterThan(50)→ "Cannot apply GreaterThan(50) because Between(1, 10) already requires values less than or equal to 10."Root cause
An asymmetry in the interval engines. Bounds are first-class — each remembers the constraint that set it (
_minConstraint,_maxConstraint,_stepConstraint,_allowedConstraint). Exclusions are second-class:_excludedis a bare list of values with no provenance (OrdinalIntervalSpec.cs:94). So when the feasible set empties because of an exclusion,DescribeExhaustion(:323-342) has no label to name and falls back topinning = _minConstraint ?? _maxConstraint— naming a bound (or the constraint currently being applied), which is at best incomplete and at worst provably false. This is not a naive sampling model — the feasibility/sampling engine is sound; it is specifically the explanation layer that lacks the information and guesses.A second, deeper point: the "name both sides" framing assumes a binary conflict, but the algebra produces n-ary ones (
>=5∧<=5∧Except(5)is a three-way conflict). The fix must name the real participants without over-attributing a "pin" to a single bound.Scope
The identical
DescribeExhaustionshape lives in four interval engines, all in scope for this fix:OrdinalIntervalSpec— every integer,TimeSpan,DateTime,DateOnly,TimeOnlyWideIntervalSpec—Int128,UInt128(exact clone)DecimalIntervalSpec—decimal(:359, same defect with a generic "which the exclusions forbid" suffix)ContinuousIntervalSpec—double,float,Half(:264, same)Out of this PR, flagged as the same defect in bespoke code:
AnyEnum(:268) andAnyGuid(:178) carry the same self-referential "no value OneOf(...) allows remains available" when an exclusion empties their allow-list. Recommended as a tight follow-up unless folded in here.Impact
The one feature the library advertises as distinctive — a conflict that "reads as the test defect it is" — actively misleads for every exclusion-caused conflict, on a five-call chain sending the reader to the wrong constraint. Message text becomes a de-facto contract at 1.0.0 (consumers regex-match it), so freezing it wrong freezes it for good, and the structural fix gets more expensive once the pattern is relied upon.
Direction
Option 2 from the design discussion: give exclusions the same provenance bounds already carry, then rewrite
DescribeExhaustionto reason about the real emptiness structure and attribute correctly — never self-referential, never false, always naming the excluding constraint(s) plus the restriction they emptied. No general constraint solver: the feasibility engine is unchanged; only the diagnostic layer gains information and correctness.Target messages (wording to be confirmed):
Acceptance criteria
JustDummies.UnitTestspin the message content for each shape (message content is the example suite's job per ADR-0040 /WritingJustDummiesTests), across all four interval engines.Context
Surfaced by the 2026-07 JustDummies v1.0.0 readiness audit as problem #2 (MUST FIX): the library's distinctive feature provably mis-reports the cause of a conflict. Sibling of the concurrency fix in #310 / PR #311.