Skip to content

JustDummies: conflict messages misname the cause when an exclusion empties the domain #312

Description

@Reefact

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<=5Except(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
  • WideIntervalSpecInt128, UInt128 (exact clone)
  • DecimalIntervalSpecdecimal (:359, same defect with a generic "which the exclusions forbid" suffix)
  • ContinuousIntervalSpecdouble, 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions