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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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