From 04d41ab36907b0356d8a4807ce86437665c90fab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 06:16:04 +0000 Subject: [PATCH 1/2] feat(dummies): add DifferentFrom/Except exclusions to AnyString MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnyString was the only scalar builder without exclusion constraints, forcing hand-rolled retry loops for the common "a value different from the one I already hold" case. Add DifferentFrom(string) and Except(params string[]), the exclusion pair every other scalar builder already exposes, and declare them in the per-TFM public-API baselines. Strings are not ordinal-mapped, so an exclusion cannot be built into the constructive layout the way it is for numerics. It is met instead by a bounded redraw of the layout (expected collisions are ~0 for any non-trivial shape) — the same bounded escape distinct collections use to skip a duplicate. An exclusion tight enough to leave the shape unsatisfiable surfaces at generation as a seed-bearing AnyGenerationException, never as an unbounded loop; this is documented as the one case where a string generator that exists may still fail to generate. OneOf stays terminal (it returns AnyStringOneOf) and cannot combine with an exclusion. Refs: #224 --- Dummies.UnitTests/AnyStringTests.cs | 63 +++++++++++ Dummies.UnitTests/SurfaceParityTests.cs | 7 +- Dummies/AnyString.cs | 39 ++++++- .../PublicAPI/net8.0/PublicAPI.Unshipped.txt | 2 + .../netstandard2.0/PublicAPI.Unshipped.txt | 2 + Dummies/README.nuget.md | 8 +- Dummies/StringSpec.cs | 107 ++++++++++++++---- 7 files changed, 202 insertions(+), 26 deletions(-) diff --git a/Dummies.UnitTests/AnyStringTests.cs b/Dummies.UnitTests/AnyStringTests.cs index 2b987ff8..e16b7184 100644 --- a/Dummies.UnitTests/AnyStringTests.cs +++ b/Dummies.UnitTests/AnyStringTests.cs @@ -257,4 +257,67 @@ public void FragmentArgumentsAreValidated() { Check.ThatCode(() => Any.String().Containing("")).Throws(); } + [Fact(DisplayName = "DifferentFrom never returns the excluded value.")] + public void DifferentFromNeverReturnsTheExcludedValue() { + foreach (string value in Samples(Any.String().WithLength(1).Alpha().DifferentFrom("A"))) { + Check.That(value).IsNotEqualTo("A"); + } + } + + [Fact(DisplayName = "Except excludes each listed value.")] + public void ExceptExcludesEachListedValue() { + string[] forbidden = { "A", "B", "C" }; + foreach (string value in Samples(Any.String().WithLength(1).Alpha().Except("A", "B", "C"))) { + Check.That(forbidden.Contains(value)).IsFalse(); + } + } + + [Fact(DisplayName = "An exclusion preserves the declared shape: only shape-matching survivors are drawn.")] + public void ExclusionPreservesTheDeclaredShape() { + foreach (string value in Samples(Any.String().StartingWith("ORD-").WithLength(5).DifferentFrom("ORD-A"))) { + Check.That(value).StartsWith("ORD-"); + Check.That(value.Length).IsEqualTo(5); + Check.That(value).IsNotEqualTo("ORD-A"); + } + } + + [Fact(DisplayName = "Exclusions accumulate across several declarations.")] + public void ExclusionsAccumulateAcrossDeclarations() { + foreach (string value in Samples(Any.String().WithLength(1).Alpha().Except("A", "B").DifferentFrom("C"))) { + Check.That(value is "A" or "B" or "C").IsFalse(); + } + } + + [Fact(DisplayName = "An over-tight exclusion fails at generation with a bounded, seed-bearing AnyGenerationException.")] + public void OverTightExclusionThrowsSeedBearingGenerationException() { + string[] everyLetter = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".Select(letter => letter.ToString()).ToArray(); + + AnyGenerationException error = Assert.Throws( + () => Any.WithSeed(20260721).String().WithLength(1).Alpha().Except(everyLetter).Generate()); + + Check.That(error.Seed).IsEqualTo(20260721); + Check.That(error.Message).Contains("Any.WithSeed(20260721)"); + } + + [Fact(DisplayName = "A seeded exclusion is reproducible: the same seed yields the same value.")] + public void SeededExclusionIsReproducible() { + string first = Any.WithSeed(4242).String().NonEmpty().Alpha().DifferentFrom("Q").Generate(); + string second = Any.WithSeed(4242).String().NonEmpty().Alpha().DifferentFrom("Q").Generate(); + + Check.That(second).IsEqualTo(first); + } + + [Fact(DisplayName = "OneOf cannot combine with an exclusion: it stays terminal.")] + public void OneOfCannotCombineWithAnExclusion() { + Check.ThatCode(() => Any.String().DifferentFrom("x").OneOf("a", "b")).Throws(); + } + + [Fact(DisplayName = "Exclusion arguments are validated as arguments, not as conflicts.")] + public void ExclusionArgumentsAreValidated() { + Check.ThatCode(() => Any.String().DifferentFrom(null!)).Throws(); + Check.ThatCode(() => Any.String().Except(null!)).Throws(); + Check.ThatCode(() => Any.String().Except()).Throws(); + Check.ThatCode(() => Any.String().Except("a", null!)).Throws(); + } + } diff --git a/Dummies.UnitTests/SurfaceParityTests.cs b/Dummies.UnitTests/SurfaceParityTests.cs index ca2b580b..f36233b5 100644 --- a/Dummies.UnitTests/SurfaceParityTests.cs +++ b/Dummies.UnitTests/SurfaceParityTests.cs @@ -132,10 +132,13 @@ public static IEnumerable Builders() { yield return [typeof(AnyEnum), new[] { "OneOf", "Except", "DifferentFrom" }]; yield return [typeof(AnyChar), new[] { "Alpha", "AlphaNumeric", "Numeric", "UpperCase", "LowerCase", "OneOf", "Except", "DifferentFrom" }]; - // AnyString is deliberately the only scalar builder with no exclusion constraints (no OneOf/Except/DifferentFrom). + // AnyString carries the exclusion pair Except/DifferentFrom (met by a bounded redraw, since strings are not + // ordinal-mapped). Its OneOf is terminal — it returns AnyStringOneOf, a different type, so it is not a + // self-returning constraint and does not appear in this fluent-method set. yield return [typeof(AnyString), new[] { "NonEmpty", "WithLength", "WithMinLength", "WithMaxLength", "WithLengthBetween", - "StartingWith", "EndingWith", "Containing", "Alpha", "AlphaNumeric", "Numeric", "UpperCase", "LowerCase" + "StartingWith", "EndingWith", "Containing", "Alpha", "AlphaNumeric", "Numeric", "UpperCase", "LowerCase", + "Except", "DifferentFrom" }]; #if NET8_0_OR_GREATER diff --git a/Dummies/AnyString.cs b/Dummies/AnyString.cs index 9c90762b..547f4c8d 100644 --- a/Dummies/AnyString.cs +++ b/Dummies/AnyString.cs @@ -42,6 +42,10 @@ private static string V(int value) { return value.ToString(CultureInfo.InvariantCulture); } + private static string Join(string[] values) { + return string.Join(", ", values.Select(value => $"\"{value}\"")); + } + private static string RequireText(string value, string parameterName) { if (value is null) { throw new ArgumentNullException(parameterName); } if (value.Length == 0) { throw new ArgumentException("The value must not be empty.", parameterName); } @@ -202,6 +206,39 @@ public AnyString UpperCase() { return new AnyString(_source, _spec.WithCasing(LetterCasing.Upper, "UpperCase()")); } + /// + /// Requires the generated string to be none of the supplied . May be declared several + /// times; the exclusions accumulate. Unlike the shape constraints an exclusion is met by a bounded redraw + /// of the constructed layout, so an exclusion tight enough to leave the shape unsatisfiable surfaces at + /// as a seed-bearing , never as a declaration-time + /// conflict. The empty string is a valid value to exclude; a null element is not. + /// + /// The values the generated string must differ from; duplicates are ignored. + /// A new generator carrying the added constraint. + /// Thrown when is null. + /// Thrown when is empty or contains a null element. + public AnyString Except(params string[] values) { + if (values is null) { throw new ArgumentNullException(nameof(values)); } + if (values.Length == 0) { throw new ArgumentException("At least one value is required.", nameof(values)); } + if (values.Any(value => value is null)) { throw new ArgumentException("The values must not contain a null element.", nameof(values)); } + + return new AnyString(_source, _spec.WithExcluded(values, $"Except({Join(values)})")); + } + + /// + /// Requires the generated string to differ from — typically an existing value the test + /// already holds, to exercise an inequality path while preserving the declared shape. Semantically equivalent to + /// ; the name carries the intent at the call site. + /// + /// The value the generated string must differ from. + /// A new generator carrying the added constraint. + /// Thrown when is null. + public AnyString DifferentFrom(string value) { + if (value is null) { throw new ArgumentNullException(nameof(value)); } + + return new AnyString(_source, _spec.WithExcluded([value], $"DifferentFrom(\"{value}\")")); + } + /// /// Draws the string from an explicit, fixed set of instead of shaping one — the /// dummy for a value whose domain is a closed list the test does not assert on (a currency code, a well-known @@ -243,7 +280,7 @@ public AnyStringOneOf OneOf(IEnumerable values) { /// public string Generate() { - return _spec.Generate(_source.Current.Random); + return _spec.Generate(_source); } } diff --git a/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt b/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt index 51f00166..2d64bbdb 100644 --- a/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt +++ b/Dummies/PublicAPI/net8.0/PublicAPI.Unshipped.txt @@ -270,7 +270,9 @@ Dummies.AnyString Dummies.AnyString.Alpha() -> Dummies.AnyString! Dummies.AnyString.AlphaNumeric() -> Dummies.AnyString! Dummies.AnyString.Containing(string! value) -> Dummies.AnyString! +Dummies.AnyString.DifferentFrom(string! value) -> Dummies.AnyString! Dummies.AnyString.EndingWith(string! suffix) -> Dummies.AnyString! +Dummies.AnyString.Except(params string![]! values) -> Dummies.AnyString! Dummies.AnyString.Generate() -> string! Dummies.AnyString.LowerCase() -> Dummies.AnyString! Dummies.AnyString.NonEmpty() -> Dummies.AnyString! diff --git a/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt b/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt index 847752a5..9f341225 100644 --- a/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt +++ b/Dummies/PublicAPI/netstandard2.0/PublicAPI.Unshipped.txt @@ -227,7 +227,9 @@ Dummies.AnyString Dummies.AnyString.Alpha() -> Dummies.AnyString! Dummies.AnyString.AlphaNumeric() -> Dummies.AnyString! Dummies.AnyString.Containing(string! value) -> Dummies.AnyString! +Dummies.AnyString.DifferentFrom(string! value) -> Dummies.AnyString! Dummies.AnyString.EndingWith(string! suffix) -> Dummies.AnyString! +Dummies.AnyString.Except(params string![]! values) -> Dummies.AnyString! Dummies.AnyString.Generate() -> string! Dummies.AnyString.LowerCase() -> Dummies.AnyString! Dummies.AnyString.NonEmpty() -> Dummies.AnyString! diff --git a/Dummies/README.nuget.md b/Dummies/README.nuget.md index 3af9a515..9dbc2ecc 100644 --- a/Dummies/README.nuget.md +++ b/Dummies/README.nuget.md @@ -55,8 +55,12 @@ matter — and that is the point. identities with `NonEmpty`/`DifferentFrom` — and deliberately no clock-relative constraints: a reproducible test pins its reference instants explicitly. - **Values built to satisfy the constraints** — a scalar is constructed directly, - never generated-then-filtered. Only a *distinct* collection ever redraws, and only - to skip a duplicate: a **bounded** deduplicating draw, never an unbounded retry loop. + never generated-then-filtered. The one exception is excluding values from a string + (`Any.String().DifferentFrom(...)`/`Except(...)`): a string has no ordinal mapping to + build the exclusion into, so it is met by a **bounded** redraw — the same escape a + *distinct* collection uses to skip a duplicate, never an unbounded retry loop. An + exclusion tight enough to leave the shape unsatisfiable surfaces at generation as a + seed-bearing `AnyGenerationException`. - **Conflicting constraints fail fast** with a clear, actionable `ConflictingAnyConstraintException` at the moment the conflicting constraint is declared — for example `Any.String().WithLength(3).StartingWith("ORD-")`. diff --git a/Dummies/StringSpec.cs b/Dummies/StringSpec.cs index 1a201c15..5ff9ffb5 100644 --- a/Dummies/StringSpec.cs +++ b/Dummies/StringSpec.cs @@ -9,25 +9,41 @@ namespace Dummies; /// /// The immutable specification behind : length bounds, anchored fragments (prefix, -/// suffix, contained values), a character set and a letter casing — each remembering the constraint that set it, -/// so a conflict message can name both sides. Every mutation returns a new specification and cross-validates the -/// whole eagerly: an that exists can always generate. +/// suffix, contained values), a character set, a letter casing and excluded values — each remembering the +/// constraint that set it, so a conflict message can name both sides. Every mutation returns a new specification +/// and cross-validates the whole eagerly: an that exists can always generate — save for +/// an exclusion tight enough to leave the shape unsatisfiable, the one failure deferred to generation (see remarks). /// /// -/// Fragments are laid out without overlap analysis: a generated string is -/// prefix + filler + contained values + filler + suffix, so the length budget the fragments require is -/// the plain sum of their lengths. A combination that only a cleverer overlapping layout could satisfy is -/// reported as a conflict — a deliberate V1 simplification, kept explicit in the conflict messages. +/// +/// Fragments are laid out without overlap analysis: a generated string is +/// prefix + filler + contained values + filler + suffix, so the length budget the fragments require is +/// the plain sum of their lengths. A combination that only a cleverer overlapping layout could satisfy is +/// reported as a conflict — a deliberate V1 simplification, kept explicit in the conflict messages. +/// +/// +/// Exclusions (DifferentFrom/Except) are the one constraint not met by construction: strings are +/// not ordinal-mapped, so an excluded value is avoided by a bounded redraw of the constructive layout — +/// expected collisions are ≈ 0 for any non-trivial shape, the same bounded escape a distinct collection uses to +/// skip a duplicate. An exclusion tight enough to leave the shape unsatisfiable (for example excluding every +/// character a single-character length allows) is therefore the one case that surfaces at generation, as a +/// seed-bearing , rather than eagerly at declaration. +/// /// internal sealed class StringSpec { private const int DefaultLengthSpread = 16; + // Bounded escape for exclusions: even the tightest realistic satisfiable shape — a single free character in a + // ~60-value pool with all but one value excluded — is found with overwhelming probability well within this many + // draws, while a genuinely unsatisfiable exclusion fails fast. Mirrors the fixed floor of the collection dedup draw. + private const int ExclusionRedrawBudget = 10_000; + #region Statics members declarations internal static readonly StringSpec Unconstrained = new(null, null, 0, null, null, null, null, null, null, null, [], - null, null, null, null); + null, null, null, null, []); private static string V(int value) { return value.ToString(CultureInfo.InvariantCulture); @@ -47,6 +63,7 @@ private static string Characters(int count) { private readonly string? _charsetConstraint; private readonly int? _exactLength; private readonly string? _exactConstraint; + private readonly IReadOnlyList _excluded; private readonly IReadOnlyList _fragments; private readonly int? _maxLength; private readonly string? _maxConstraint; @@ -66,9 +83,11 @@ private StringSpec(int? exactLength, string? exactConstraint, string? suffix, string? suffixConstraint, IReadOnlyList fragments, CharacterSet? charset, string? charsetConstraint, - LetterCasing? casing, string? casingConstraint) { + LetterCasing? casing, string? casingConstraint, + IReadOnlyList excluded) { _exactLength = exactLength; _exactConstraint = exactConstraint; + _excluded = excluded; _minLength = minLength; _minConstraint = minConstraint; _maxLength = maxLength; @@ -87,12 +106,12 @@ private StringSpec(int? exactLength, string? exactConstraint, /// /// Whether no constraint has been declared yet — the pristine state a generator from /// starts in, before any fluent constraint narrows it. Used to keep a terminal constraint (OneOf) from - /// being combined with the shaping ones. + /// being combined with the shaping ones (an exclusion counts as a declared constraint). /// internal bool IsUnconstrained => _exactLength is null && _minLength == 0 && _maxLength is null && _prefix is null && _suffix is null && _fragments.Count == 0 && - _charset is null && _casing is null; + _charset is null && _casing is null && _excluded.Count == 0; /// Fixes the exact length; declared once per generator. internal StringSpec WithExactLength(int length, string applying) { @@ -100,7 +119,7 @@ internal StringSpec WithExactLength(int length, string applying) { StringSpec candidate = new(length, applying, _minLength, _minConstraint, _maxLength, _maxConstraint, _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, - _charset, _charsetConstraint, _casing, _casingConstraint); + _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -111,7 +130,7 @@ internal StringSpec WithMinLength(int length, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, length, applying, _maxLength, _maxConstraint, _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, - _charset, _charsetConstraint, _casing, _casingConstraint); + _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -122,7 +141,7 @@ internal StringSpec WithMaxLength(int length, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, length, applying, _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, - _charset, _charsetConstraint, _casing, _casingConstraint); + _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -133,7 +152,7 @@ internal StringSpec WithPrefix(string prefix, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, prefix, applying, _suffix, _suffixConstraint, _fragments, - _charset, _charsetConstraint, _casing, _casingConstraint); + _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -144,7 +163,7 @@ internal StringSpec WithSuffix(string suffix, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, _prefix, _prefixConstraint, suffix, applying, _fragments, - _charset, _charsetConstraint, _casing, _casingConstraint); + _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -155,7 +174,7 @@ internal StringSpec WithFragment(string fragment, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, _prefix, _prefixConstraint, _suffix, _suffixConstraint, fragments, - _charset, _charsetConstraint, _casing, _casingConstraint); + _charset, _charsetConstraint, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -166,7 +185,7 @@ internal StringSpec WithCharset(CharacterSet charset, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, - charset, applying, _casing, _casingConstraint); + charset, applying, _casing, _casingConstraint, _excluded); return candidate.Validated(applying); } @@ -177,13 +196,42 @@ internal StringSpec WithCasing(LetterCasing casing, string applying) { StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, - _charset, _charsetConstraint, casing, applying); + _charset, _charsetConstraint, casing, applying, _excluded); + + return candidate.Validated(applying); + } + + /// Adds values the generated string must avoid; may be declared several times, the exclusions accumulate. + internal StringSpec WithExcluded(IReadOnlyList values, string applying) { + List excluded = new(_excluded); + foreach (string value in values) { + if (!excluded.Contains(value, StringComparer.Ordinal)) { excluded.Add(value); } + } + + StringSpec candidate = new(_exactLength, _exactConstraint, _minLength, _minConstraint, _maxLength, _maxConstraint, + _prefix, _prefixConstraint, _suffix, _suffixConstraint, _fragments, + _charset, _charsetConstraint, _casing, _casingConstraint, excluded); return candidate.Validated(applying); } - /// Builds one string satisfying the whole specification — laid out directly, never generate-then-retry. - internal string Generate(Random random) { + /// + /// Builds one string satisfying the whole specification — laid out directly, never generate-then-retry. The one + /// redraw is to skip an excluded value: a bounded escape (expected collisions ≈ 0 for any non-trivial shape); an + /// exhausted budget means the exclusions leave the shape unsatisfiable, reported with the seed to replay. + /// + internal string Generate(RandomSource source) { + Random random = source.Current.Random; + if (_excluded.Count == 0) { return BuildCandidate(random); } + + for (int collisions = 0;;) { + string candidate = BuildCandidate(random); + if (!_excluded.Contains(candidate, StringComparer.Ordinal)) { return candidate; } + if (++collisions >= ExclusionRedrawBudget) { throw Exhausted(source); } + } + } + + private string BuildCandidate(Random random) { int required = RequiredLength(); int effectiveMin = Math.Max(_minLength, required); // Long arithmetic: a huge declared minimum must saturate instead of overflowing past int.MaxValue. @@ -205,6 +253,23 @@ internal string Generate(Random random) { return builder.ToString(); } + private AnyGenerationException Exhausted(RandomSource source) { + int seed = source.Current.Seed; + // A string generator draws only from its own source, so the seed replays the run fully — never the partial hint. + string replay = source.ReplayHint(seed); + string message = + $"Could not generate a string that satisfies the declared shape while excluding {DescribeExcluded()}: " + + $"no candidate survived {V(ExclusionRedrawBudget)} draws, so the exclusions leave the shape unsatisfiable " + + "(for example excluding every value a fixed short length allows). Loosen the exclusions or widen the shape. " + + replay; + + return new AnyGenerationException(message, seed); + } + + private string DescribeExcluded() { + return string.Join(", ", _excluded.Select(value => $"\"{value}\"")); + } + private static void AppendFiller(StringBuilder builder, Random random, string pool, int count) { for (int i = 0; i < count; i++) { builder.Append(pool[random.Next(pool.Length)]); From 5b17aa9dd0088f723b66e25e178308ccee197948 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 06:16:04 +0000 Subject: [PATCH 2/2] docs(dummies): record ADR-0033 for string-exclusion redraw Record the decision behind #224 (English canonical plus French translation, indexed): AnyString exclusions are met by a bounded redraw and an unsatisfiable exclusion fails at generation with a seed-bearing error, the one departure from the constructive "built to satisfy" rule for scalars. Sibling to ADR-0013. Accepted by the maintainer. Refs: #224 --- ...ing-exclusions-with-a-bounded-redraw.fr.md | 83 +++++++++++++++++++ ...string-exclusions-with-a-bounded-redraw.md | 83 +++++++++++++++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 3 files changed, 167 insertions(+) create mode 100644 doc/handwritten/for-maintainers/adr/0033-meet-string-exclusions-with-a-bounded-redraw.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0033-meet-string-exclusions-with-a-bounded-redraw.md diff --git a/doc/handwritten/for-maintainers/adr/0033-meet-string-exclusions-with-a-bounded-redraw.fr.md b/doc/handwritten/for-maintainers/adr/0033-meet-string-exclusions-with-a-bounded-redraw.fr.md new file mode 100644 index 00000000..64750bae --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0033-meet-string-exclusions-with-a-bounded-redraw.fr.md @@ -0,0 +1,83 @@ +# ADR-0033 | Traiter les exclusions de chaînes par un tirage borné + +🌍 🇬🇧 [English](0033-meet-string-exclusions-with-a-bounded-redraw.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-22 +**Décideurs :** Reefact + +## Contexte + +Dummies construit un scalaire directement pour satisfaire ses contraintes — jamais généré-puis-filtré — et détecte les contradictions au moment de la déclaration, de sorte qu'un générateur scalaire qui existe peut toujours générer. Il évite aussi les boucles de nouvelles tentatives cachées et non bornées. + +Tous les builders scalaires sauf un exposent un trio d'exclusion (`OneOf`/`Except`/`DifferentFrom`). Pour les types à projection ordinale (entiers, types temporels, `char`, `Guid`), une exclusion est intégrée à la construction : le tirage est projeté sur le k-ième ordinal non exclu du domaine en une passe, et le fait que les exclusions laissent le domaine non vide se compte à bas coût à la déclaration. + +Les chaînes n'ont pas de projection ordinale. Un `AnyString` est assemblé par disposition — préfixe, remplissage, valeurs contenues, suffixe — sur un domaine effectivement non borné. Une valeur exclue ne peut pas être retirée de ce domaine par construction, et le fait qu'un ensemble d'exclusions laisse une forme satisfaisable n'est pas décidable à bas coût en général : c'est trivial pour une longueur fixe d'un caractère, mais cela croît de façon combinatoire avec la longueur et le jeu de caractères. + +`AnyString` était le seul builder scalaire sans contraintes d'exclusion, alors que « une valeur différente de celle que je détiens déjà » — tester un chemin d'inégalité avec un identifiant de type chaîne tout en préservant son format — est un besoin courant de chaîne factice (issue #224). L'écrire à la main avec une boucle de nouvelles tentatives oublie généralement la source seedée et casse la reproductibilité, précisément le piège que la bibliothèque existe pour éviter. + +La bibliothèque accepte déjà un endroit où une valeur qu'un appelant a déclarée peut malgré tout ne pas se matérialiser : une collection distincte sur un domaine non dénombrable tire-et-déduplique sous un budget borné et échoue à la génération, de manière reproductible, lorsqu'elle ne le peut pas (ADR-0013). `AnyString.OneOf` est un générateur terminal distinct qui ne se combine pas avec les autres contraintes (ADR-0030). + +## Décision + +`AnyString.DifferentFrom`/`Except` sont satisfaits par un nouveau tirage borné de la disposition constructive, et une exclusion qui rend la forme insatisfaisable échoue à la génération par une erreur reproductible portant la seed, plutôt qu'au moment de la déclaration. + +## Justification + +Comme une chaîne ne porte aucune projection ordinale, une exclusion ne peut pas être intégrée à la disposition comme pour les types ordinaux ; un nouveau tirage est donc la seule stratégie générale — la même échappatoire qu'une collection distincte utilise déjà lorsqu'elle ne sait pas compter son domaine. La borner garantit la terminaison et transforme une exclusion insatisfaisable en un échec diagnostiquable et reproductible plutôt qu'en blocage ; porter la seed maintient cet échec dans le contrat de reproductibilité de la bibliothèque. + +L'échec est différé plutôt qu'anticipé parce que la satisfaisabilité d'une chaîne sous exclusion n'est pas décidable à bas coût en général. Un contrôle complet au moment de la déclaration est donc irréalisable, et un contrôle partiel diagnostiquerait certaines specs insatisfaisables à la déclaration et d'autres seulement à la génération — une couture incohérente, pire qu'une règle unique et prévisible. Différer uniformément est le choix honnête, et cela confine l'écart aux seules exclusions : toute autre contrainte de chaîne reste constructive et validée par anticipation. + +Accepter cet écart est justifié car les alternatives sont pires : laisser le manque garde le builder le plus utilisé comme le seul scalaire incapable d'exclure et renvoie les utilisateurs vers des boucles de nouvelles tentatives qui cassent le seed, tandis qu'imposer un verdict anticipé exige une procédure de décision que le domaine n'admet pas à bas coût. Le coût — un unique cas, cerné et documenté, où un générateur de chaîne qui existe peut malgré tout échouer — est le compromis déjà accepté pour les collections distinctes, et les collisions attendues sont ≈ 0 pour toute forme non triviale, de sorte que le chemin rapide constructif est préservé en pratique. + +Le budget de nouveau tirage, le contenu de l'exception et la propagation de la seed relèvent de l'implémentation, documentés dans le code `Dummies` (`StringSpec`) et la documentation utilisateur de Dummies — pas ici. + +## Alternatives envisagées + +### Laisser `AnyString` sans contraintes d'exclusion + +Envisagé parce que cela préservait la règle purement constructive des scalaires et n'exigeait aucun nouveau canal d'échec. Rejeté parce que cela laissait le builder le plus utilisé comme le seul scalaire incapable d'exprimer une exclusion, forçant des boucles de nouvelles tentatives écrites à la main qui cassent silencieusement le seed. + +### Décider la satisfaisabilité par anticipation, comme les builders ordinaux + +Envisagé parce que le diagnostic au moment de la déclaration est la norme de la bibliothèque pour les contraintes contradictoires. Rejeté parce que la satisfaisabilité d'une chaîne sous exclusion n'est pas décidable à bas coût en général ; un contrôle anticipé partiel serait une couture incohérente, diagnostiquant certaines specs tôt et d'autres tard. + +### Tirer sans borne + +Envisagé car une exclusion satisfaisable finirait par aboutir. Rejeté parce qu'une exclusion insatisfaisable boucquerait indéfiniment, violant le principe d'absence de boucles cachées non bornées. + +### Évitement conscient de la spec à la disposition + +Envisagé parce que construire la chaîne pour esquiver l'ensemble exclu garderait l'exclusion constructive et anticipée. Rejeté comme disproportionné : éviter correctement un ensemble exclu arbitraire sur les positions libres de la disposition est complexe pour un chemin que le nouveau tirage n'emprunte pour ainsi dire jamais ; on pourra le réexaminer si les faits le justifient. + +## Conséquences + +### Positives + +* La paire d'exclusion est désormais uniforme sur tous les builders scalaires ; le besoin courant « identifiant différent, même forme » est servi, seedé et reproductible. +* Le chemin rapide constructif est inchangé pour toute spec sans exclusion, et en pratique pour les exclusions aussi (collisions ≈ 0). +* Une exclusion insatisfaisable échoue de manière sûre, reproductible, et nomme la seed à rejouer — cohérent avec l'ADR-0013. + +### Négatives + +* « Un `AnyString` qui existe peut toujours générer » ne tient plus sans condition : une exclusion trop serrée est le seul cas différé à la génération. +* Le moment de l'échec d'une exclusion de chaîne insatisfaisable diffère du diagnostic anticipé, au moment de la déclaration, que donnent les builders ordinaux. + +### Risques + +* Un budget mal calibré pourrait faire échouer une forme théoriquement satisfaisable mais extrêmement serrée. Mesure : documenter le budget et le réviser sur la base de faits, plutôt que de présenter l'échec comme impossible (la posture de l'ADR-0013). +* Les utilisateurs pourraient s'attendre à ce que l'exclusion de chaîne soit constructive comme pour les builders numériques. Mesure : énoncer explicitement le nouveau tirage et son échec différé dans la documentation du builder et le readme de Dummies. + +## Actions de suivi + +* Documenter le nouveau tirage et l'échec différé portant la seed dans le readme de Dummies et la documentation du builder (fait dans la pull request d'implémentation). +* Réexaminer le budget si l'usage réel révèle des épuisements indus. +* Envisager l'évitement conscient de la spec seulement si les faits montrent que le tirage borné est insuffisant. + +## Références + +* [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.fr.md) — le canal frère « tirage borné avec échec différé ». +* [ADR-0030](0030-draw-arbitrary-strings-from-an-explicit-terminal-set.fr.md) — `AnyString.OneOf` reste terminal et ne se combine pas avec les exclusions. +* [ADR-0020](0020-materialize-dummies-only-through-generate.fr.md) — les dummies se matérialisent uniquement via `Generate()`. +* `StringSpec` et `AnyString` dans le projet `Dummies` ; le readme NuGet de Dummies. +* Issue [#224](https://github.com/Reefact/first-class-errors/issues/224). diff --git a/doc/handwritten/for-maintainers/adr/0033-meet-string-exclusions-with-a-bounded-redraw.md b/doc/handwritten/for-maintainers/adr/0033-meet-string-exclusions-with-a-bounded-redraw.md new file mode 100644 index 00000000..c04cea8a --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0033-meet-string-exclusions-with-a-bounded-redraw.md @@ -0,0 +1,83 @@ +# ADR-0033 | Meet string exclusions with a bounded redraw + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0033-meet-string-exclusions-with-a-bounded-redraw.fr.md) + +**Status:** Accepted +**Date:** 2026-07-22 +**Decision Makers:** Reefact + +## Context + +Dummies builds a scalar directly to satisfy its constraints — never generated-then-filtered — and detects contradictions eagerly at declaration, so a scalar generator that exists can always generate. It also avoids hidden unbounded retry loops. + +Every scalar builder but one exposes an exclusion trio (`OneOf`/`Except`/`DifferentFrom`). For the ordinal-mapped types (integers, temporal types, `char`, `Guid`) an exclusion is built into construction: the draw is mapped onto the k-th non-excluded value of the domain in one pass, and whether the exclusions leave the domain non-empty is counted cheaply at declaration. + +Strings have no ordinal mapping. An `AnyString` is assembled by layout — prefix, filler, contained values, suffix — over an effectively unbounded domain. An excluded value cannot be projected out of that domain by construction, and whether a set of exclusions leaves a shape satisfiable is not cheaply decidable in general: it is trivial for a fixed one-character length, but grows combinatorially with length and character set. + +`AnyString` was the only scalar builder with no exclusion constraints, even though "a value different from the one I already hold" — testing an inequality path with a string identifier while keeping its format — is a common dummy-string need (issue #224). Hand-rolling it with a retry loop typically forgets the seeded source and breaks reproducibility, the exact trap the library exists to prevent. + +The library already accepts one place where a value that a caller declared may still fail to materialize: a distinct collection over an uncountable domain draws-and-deduplicates under a bounded budget and fails at generation, reproducibly, when it cannot (ADR-0013). `AnyString.OneOf` is a separate, terminal generator that does not combine with other constraints (ADR-0030). + +## Decision + +`AnyString.DifferentFrom`/`Except` are satisfied by a bounded redraw of the constructive layout, and an exclusion that leaves the shape unsatisfiable fails at generation with a reproducible, seed-bearing error rather than eagerly at declaration. + +## Rationale + +Because a string carries no ordinal mapping, an exclusion cannot be built into the layout the way it is for ordinal types, so a redraw is the only general strategy — the same escape a distinct collection already uses when it cannot count its domain. Bounding it preserves termination and turns an unsatisfiable exclusion into a diagnosable, reproducible failure rather than a hang; carrying the seed keeps that failure within the library's reproducibility contract. + +The failure is deferred rather than eager because string satisfiability under exclusion is not cheaply decidable in general. A complete declaration-time check is therefore infeasible, and a partial one would diagnose some unsatisfiable specs at declaration and others only at generation — an inconsistent seam worse than a single, predictable rule. Deferring uniformly is the honest choice, and it confines the departure to exclusions alone: every other string constraint stays constructive and eagerly validated. + +Accepting that departure is warranted because the alternatives are worse: leaving the gap keeps the most-used builder the only scalar that cannot exclude and pushes users back to seed-breaking retry loops, while forcing an eager verdict demands a decision procedure the domain does not cheaply admit. The cost — one narrowly-scoped, documented case where a string generator that exists may still fail — is the trade already accepted for distinct collections, and expected collisions are ≈ 0 for any non-trivial shape, so the constructive fast path is preserved in practice. + +The redraw budget, the exception payload, and the seed propagation are implementation, documented in the `Dummies` code (`StringSpec`) and the Dummies user documentation — not here. + +## Alternatives Considered + +### Leave `AnyString` without exclusion constraints + +Considered because it preserved the pure constructive rule for scalars and needed no new failure channel. Rejected because it left the most-used builder the only scalar that cannot express exclusion, forcing hand-rolled retry loops that silently break seeding. + +### Decide satisfiability eagerly, as the ordinal builders do + +Considered because declaration-time diagnosis is the library's norm for contradictory constraints. Rejected because string satisfiability under exclusion is not cheaply decidable in general; a partial eager check would be an inconsistent seam, diagnosing some specs early and others late. + +### Redraw without a bound + +Considered because a satisfiable exclusion would eventually succeed. Rejected because an unsatisfiable one would loop forever, violating the no-hidden-unbounded-loops principle. + +### Spec-aware layout avoidance + +Considered because constructing the string to dodge the excluded set would keep exclusion constructive and eager. Rejected as disproportionate: correctly avoiding an arbitrary excluded set across the layout's free positions is complex for a path the redraw takes virtually never; it can be revisited if evidence warrants. + +## Consequences + +### Positive + +* The exclusion pair is now uniform across every scalar builder; the common "different identifier, same shape" need is served, seeded and reproducible. +* The constructive fast path is unchanged for every spec without exclusions, and in practice for exclusions too (collisions ≈ 0). +* An unsatisfiable exclusion fails safely, reproducibly, and names the seed to replay — consistent with ADR-0013. + +### Negative + +* "An `AnyString` that exists can always generate" no longer holds unconditionally: an over-tight exclusion is the one case deferred to generation. +* Failure timing for an unsatisfiable string exclusion differs from the eager, declaration-time diagnosis the ordinal builders give. + +### Risks + +* A poorly tuned budget could fail a theoretically satisfiable but extremely tight shape. Mitigation: keep the budget documented and revise it on evidence, rather than describing failure as impossible (the posture of ADR-0013). +* Users may expect string exclusion to be constructive like the numeric builders. Mitigation: state the redraw and its deferred failure explicitly in the builder documentation and the Dummies readme. + +## Follow-up Actions + +* Document the redraw and the deferred, seed-bearing failure in the Dummies readme and the builder documentation (done in the implementing pull request). +* Revisit the budget if real usage reveals false exhaustion. +* Consider spec-aware avoidance only if evidence shows the bounded redraw is inadequate. + +## References + +* [ADR-0013](0013-gate-distinct-collections-by-cardinality-else-bounded-draw.md) — the sibling bounded-draw-with-deferred-failure channel. +* [ADR-0030](0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md) — `AnyString.OneOf` stays terminal and does not combine with exclusions. +* [ADR-0020](0020-materialize-dummies-only-through-generate.md) — dummies materialize only through `Generate()`. +* `StringSpec` and `AnyString` in the `Dummies` project; the Dummies NuGet readme. +* Issue [#224](https://github.com/Reefact/first-class-errors/issues/224). diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index dcec90c0..d01e827d 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -215,3 +215,4 @@ Optional supporting material: | [ADR-0030](0030-draw-arbitrary-strings-from-an-explicit-terminal-set.md) | Draw arbitrary strings from an explicit, terminal value set | Accepted | | [ADR-0031](0031-name-any-factories-after-their-clr-type.md) | Name Any's scalar factories after their CLR type | Accepted | | [ADR-0032](0032-draw-arbitrary-values-from-an-explicit-top-level-pool.md) | Draw arbitrary values from an explicit, top-level choice pool | Accepted | +| [ADR-0033](0033-meet-string-exclusions-with-a-bounded-redraw.md) | Meet string exclusions with a bounded redraw | Accepted |