fix(justdummies): name the excluding constraint in conflict messages - #313
Merged
Merged
Conversation
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
force-pushed
the
claude/justdummies-conflict-message-provenance
branch
from
July 27, 2026 10:59
8d51039 to
6eced81
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Eager conflict detection is the library's flagship diagnostic — an impossible
Arrangethrows 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, andDescribeExhaustionattributes 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
Changes
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.GenerateOrdinal/Generate,Contains,Cardinality,IsSatisfiableand the lattice/grid maths are byte-for-byte unchanged — the provenance is consulted only when a conflict message must be built.DescribeExhaustionwas 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∧<=5∧Except(5)) without over-attributing a "pin" to one bound.Between(0,1).OneOf(1,3).Except(1)—3is out of range), so "forbids every valueOneOf(...)allows" would be false. When the allow-list was narrowed by another constraint the message says "…every valueOneOf(…)allows that the other constraints leave"; the bare form is used only when no allowed value was dropped.Except(1)because it forbids …") rather than repeating the constraint on both sides of "because".AnyGenerationExceptiondraw-time paths are untouched.Before → after
Testing
dotnet build FirstClassErrors.sln— 0 warnings, 0 errors.dotnet test FirstClassErrors.sln— 1799 tests across 12 projects, 0 failures, includingFirstClassErrors.Testing.UnitTests, which draws from JustDummies per ADR-0026.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 legalBetween × MultipleOf × OneOf × Exceptcombination on each engine (ordinal, decimal, continuous, and the 128-bit sibling inModernTypeInvariantProperties), 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).dotnet build JustDummies/JustDummies.csprojunder the CI warning ratchet (GITHUB_ACTIONS=true,--no-incremental): 0 warnings.dotnet build JustDummies.PropertyTests -c Release -f net472 -p:EnableNet472Floor=true: 0 errors.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-floorjob needs Windows, and this container has neither .NET Framework nor Mono. The floor build was run (above); the run itself is CI's.Documentation
doc/updateddoc/handwritten/for-users/README.fr.md) updated if user-facing behavior changedNo user-facing document quotes these runtime exception strings, and the public API is unchanged, so there is nothing to keep in sync.
JustDummies/CHANGELOG.mdis left untouched: its Unreleased section is drafted from merged pull requests by.github/workflows/changelog.yml.Architecture decisions
Proposed: 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
AnyEnumandAnyGuidgenerators, which do not route through these interval engines and were kept out of this PR's scope.🤖 Generated with Claude Code