Skip to content

fix(justdummies): name the excluding constraint in conflict messages - #313

Merged
Reefact merged 3 commits into
mainfrom
claude/justdummies-conflict-message-provenance
Jul 27, 2026
Merged

fix(justdummies): name the excluding constraint in conflict messages#313
Reefact merged 3 commits into
mainfrom
claude/justdummies-conflict-message-provenance

Conversation

@Reefact

@Reefact Reefact commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Summary

Eager conflict detection is the library's flagship diagnostic — an impossible Arrange throws a message that "names both sides" (ADR-0035). That promise held for bound-vs-bound conflicts but broke whenever an exclusion (NonZero/Except/DifferentFrom) was the real cause: the message named a bound (or the constraint being applied) and was self-referential or provably false. Exclusions now carry the same provenance bounds already carry, and DescribeExhaustion attributes the emptiness to the constraint that actually caused it — verified true against every legal combination by an exhaustive audit, and kept true by a standing property.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Refactoring
  • Analyzer / diagnostic change
  • Tests
  • Documentation
  • Build / CI / tooling

Changes

  • The four interval engines that share the exhaustion path — OrdinalIntervalSpec (integers, TimeSpan, DateTime, DateOnly, TimeOnly), WideIntervalSpec (Int128/UInt128), DecimalIntervalSpec (decimal), ContinuousIntervalSpec (double/float/Half) — now store exclusions as (constraint label, ordinals) pairs instead of a bare value list. Bounds were already first-class (_minConstraint, _maxConstraint, _stepConstraint, _allowedConstraint); exclusions were the one second-class participant, and that asymmetry was the root cause.
  • Each engine still derives its flat, draw-time exclusion set from those pairs, so GenerateOrdinal / Generate, Contains, Cardinality, IsSatisfiable and the lattice/grid maths are byte-for-byte unchanged — the provenance is consulted only when a conflict message must be built.
  • DescribeExhaustion was rewritten to reason about the real emptiness structure. ExcludingConstraintsInEffect() returns the distinct exclusion constraints that actually bit — those forbidding a value the interval, lattice and allow-list would otherwise permit — so an exclusion whose values fall outside the surviving domain is never named. The message names those culprit(s) and the restriction they emptied, handling the n-ary case (>=5<=5Except(5)) without over-attributing a "pin" to one bound.
  • The allow-list claim is qualified, not overstated. An allowed value can be dropped by a bound or the lattice rather than by an exclusion (Between(0,1).OneOf(1,3).Except(1)3 is out of range), so "forbids every value OneOf(...) allows" would be false. When the allow-list was narrowed by another constraint the message says "…every value OneOf(…) allows that the other constraints leave"; the bare form is used only when no allowed value was dropped.
  • No self-echo. When the applied constraint is itself the sole cause, the clause refers back to it as "it" ("Cannot apply Except(1) because it forbids …") rather than repeating the constraint on both sides of "because".
  • Bound-vs-bound conflicts, allow-list/lattice redeclaration conflicts, and the AnyGenerationException draw-time paths are untouched.

Before → after

Any.Byte().NonZero().Zero()
  before: Cannot apply Zero() because Zero() already pins the value to 0.               (self-referential)
  after:  Cannot apply Zero() because NonZero() forbids 0, the only value Zero() leaves.

Any.Int32().Except(5).GreaterThanOrEqualTo(5).LessThanOrEqualTo(5)
  before: ... because GreaterThanOrEqualTo(5) already pins the value to 5.              (false: GTE(5) allows 5..MaxValue)
  after:  ... because Except(5) forbids 5, the only value GreaterThanOrEqualTo(5) and LessThanOrEqualTo(5) leave.

Any.Int32().MultipleOf(5).Except(0).Between(-4, 4)
  before: ... because no Int32 value MultipleOf(5) allows remains between -4 and 4.     (false: MultipleOf(5) yields 0; Except(0) is the cause)
  after:  ... because Except(0) forbids every MultipleOf(5) value between -4 and 4.

Any.Int32().Except(1).Between(0, 1).OneOf(1, 3)
  after:  ... because Except(1) forbids every value OneOf(1, 3) allows that the other constraints leave.   (3 was dropped by the range, not forbidden)

Any.Byte().Zero().NonZero()
  after:  Cannot apply NonZero() because it forbids 0, the only value Zero() leaves.    (no "NonZero() because NonZero()" echo)

Testing

  • dotnet build FirstClassErrors.sln — 0 warnings, 0 errors.
  • dotnet test FirstClassErrors.sln — 1799 tests across 12 projects, 0 failures, including FirstClassErrors.Testing.UnitTests, which draws from JustDummies per ADR-0026.
  • Analyzer tests pass (FirstClassErrors.Analyzers.UnitTests) — as part of the solution-wide run above.

Additionally:

  • ConflictMessageProvenanceTests (example suite, ADR-0040) adds one example per engine that the offending exclusion is named, a regression guard that a bound-vs-bound message still names both sides, a guard that the allow-list claim is qualified rather than overstated, and a guard against the self-echo. Confirmed red before the fix and flipped to green engine-by-engine.
  • ConflictMessageTruthfulnessProperties (property suite) adds the class-level guarantee behind those examples: for every legal Between × MultipleOf × OneOf × Except combination on each engine (ordinal, decimal, continuous, and the 128-bit sibling in ModernTypeInvariantProperties), it checks the message against a feasibility oracle recomputed independently — every universal claim must be literally true, no bare allow-list claim on a narrowed list, no self-echo. This is the one property that reads message text; it asserts truthfulness, not wording, and it fails on the pre-fix engines (falsifiability confirmed by mutation).
  • Exhaustive audit. Before writing the property, a temporary brute-force harness enumerated every combination over a small universe on all four engines, first surfacing 51,746 false allow-list messages, then running fully clean after the fix, with all four message branches (pin, range, lattice, allow-list) proven non-vacuously exercised. The harness was removed; the property above now carries that coverage forward.
  • dotnet build JustDummies/JustDummies.csproj under the CI warning ratchet (GITHUB_ACTIONS=true, --no-incremental): 0 warnings. dotnet build JustDummies.PropertyTests -c Release -f net472 -p:EnableNet472Floor=true: 0 errors.
  • The public API baseline is untouched (git diff -- JustDummies/PublicAPI/ is empty): the change is internal to the engines.

The net472 support floor's test run was not exercised locally: the framework-floor job needs Windows, and this container has neither .NET Framework nor Mono. The floor build was run (above); the run itself is CI's.

Documentation

  • Public API / error documentation updated
  • README / doc/ updated
  • French translation (doc/handwritten/for-users/README.fr.md) updated if user-facing behavior changed
  • No documentation change required

No user-facing document quotes these runtime exception strings, and the public API is unchanged, so there is nothing to keep in sync. JustDummies/CHANGELOG.md is left untouched: its Unreleased section is drafted from merged pull requests by .github/workflows/changelog.yml.

Architecture decisions

  • No architectural decision in this pull request
  • New decision recorded — ADR drafted as Proposed: ADR-____
  • Supersedes an existing ADR — successor proposed, status not flipped: ADR-____
  • ⚠️ Conflicts with an existing ADR — flagged for the maintainer: ADR-____

Advisory ADR check done. This change realizes ADR-0035's stated behaviour — a run-time ConflictingAnyConstraintException "whose message names both sides" — for the exclusion case, rather than establishing or changing a decision, so no new ADR is drafted (as the issue anticipated). It follows ADR-0040 by putting the message-content examples in the example suite; the added property asserts message truthfulness (a real input space), a deliberate and documented departure from "assert types, not text" that the test file explains inline. No public-API, cross-cutting-invariant, or platform-floor decision is embarked.

Related issues

Closes #312

A follow-up, #314, tracks the same defect class in the bespoke AnyEnum and AnyGuid generators, which do not route through these interval engines and were kept out of this PR's scope.


🤖 Generated with Claude Code

Eager conflict detection promises a message that names both sides of an
impossible Arrange (ADR-0035). That held for bound-vs-bound conflicts but
broke whenever an exclusion (NonZero/Except/DifferentFrom) was the real
cause: the four interval engines tracked bounds with provenance
(_minConstraint, _maxConstraint, _stepConstraint, _allowedConstraint) but
kept exclusions as a bare list of values, so DescribeExhaustion had no
label to name and fell back to a bound — producing messages that were
self-referential ("Zero() already pins the value to 0") or provably false
("GreaterThanOrEqualTo(5) already pins the value to 5", which allows
5..MaxValue).

Give exclusions the same provenance bounds already carry: each engine now
stores exclusions as (constraint label, ordinals) pairs and derives its
flat draw-time set from them. DescribeExhaustion attributes the emptiness
to the exclusion constraints that actually bit — those forbidding a value
the interval, lattice and allow-list would otherwise permit — naming the
n-ary participants without over-attributing a pin to a single bound. The
feasibility and sampling engines are unchanged; only the diagnostic layer
gains the information it was missing.

Two correctness details fall out of an exhaustive audit of the message
against every legal combination:

* The allow-list claim is qualified. An allowed value can be dropped by a
  bound or the lattice rather than by an exclusion, so "forbids every value
  OneOf(...) allows" would overstate the exclusion's reach. When the
  allow-list was narrowed by another constraint the message says "every
  value OneOf(...) allows that the other constraints leave", which stays
  true; the bare form is used only when no allowed value was dropped.

* The message no longer echoes the applied constraint. When the applied
  constraint is itself the sole cause, the clause refers back to it as "it"
  ("Cannot apply Except(1) because it forbids ...") instead of repeating the
  constraint on both sides of "because".

The fix spans all four engines that share the exhaustion path: ordinal
(integers, TimeSpan, DateTime, DateOnly, TimeOnly), wide (Int128/UInt128),
decimal, and continuous (double/float/Half). Bound-vs-bound messages are
untouched, the public API baseline is unchanged, and the build stays at
0 warnings.

Refs: #312
@Reefact
Reefact force-pushed the claude/justdummies-conflict-message-provenance branch from 8d51039 to 6eced81 Compare July 27, 2026 10:59
claude and others added 2 commits July 27, 2026 11:31
The example suite pins the specific exclusion-conflict messages; this adds
the class-level guarantee behind them. For every legal combination of
Between / MultipleOf / OneOf / Except on each interval engine, the property
catches the ConflictingAnyConstraintException and checks its message
against a feasibility oracle recomputed independently over a small integer
universe: every universal claim ("forbids every ...", "every value
between ...") must be backed by that ground truth, a bare allow-list claim
must not be used when a bound or the lattice already narrowed the
allow-list, and the message must not echo the applied constraint on both
sides of "because".

This is the one property that reads message text rather than only the
exception type. The rule "assert types, not text" (ADR-0040) targets
unstable wording; what is asserted here is truthfulness, not phrasing —
an invariant with a real input space and no type-level proxy — so it is a
property by that same document's core question. It is coupled only to the
stable claim fragments, and it fails on the pre-fix engines (the audit
that motivated it counted tens of thousands of false messages), which is
the falsifiability the suite requires.

The oracle and generators are shared; ordinal, decimal and continuous run
in the netstandard-safe file, and the 128-bit sibling runs in
ModernTypeInvariantProperties, which the net472 floor leg excludes.

Refs: #312
@Reefact
Reefact merged commit 99468c0 into main Jul 27, 2026
16 checks passed
@Reefact
Reefact deleted the claude/justdummies-conflict-message-provenance branch July 27, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants