CultNet selection cut1, commit 2f: the Q-J fix (canonical decimal numbers)
Corrects commit 1's Number field: docs/cultnet-selection-cut.md section 2
"Numbers" (added 2026-09-22, superseding the map's own stale float64 text)
specifies canonical decimal strings per the operator's Q-J ruling, and
commits 0/1 shipped double instead - the Rust Hands caught the conflict
before writing a line and stopped; this is the correction the map's
Progress block calls for before Rust starts.
- CultNetFieldPredicate.Number: double? -> string?. New CultNetCanonicalNumber
(CultNetSelection.cs) is the wire's canonical grammar and comparator:
IsCanonical validates ^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$ and excludes "-0"
(the regex alone matches it); Compare orders by sign, then the integer
part's length, then its digits, then the fraction padded on the right to
equal length - a pure string function, no double anywhere on the path.
ValidateField refuses a non-canonical number at the door
(selection_invalid) rather than normalising it.
- CultNetSelectionEvaluator.CompareNumber now takes two canonical strings and
switches on CultNetCanonicalNumber.Compare's sign instead of comparing
doubles directly.
- CultCache.cs: CultDocumentDescriptor.TryGetIndexNumber and
PersistedMember.NumberOf render a member's exact canonical decimal instead
of a double. An integer renders exact (no 2^53 limit); decimal strips
trailing fractional zeros; float/double use .NET's shortest round-trip
ToString(), rewritten out of exponent notation to positional digits
(ExpandScientificNotation) before the shared CanonicalizeDecimalDigits
strips zeros and collapses a negative-zero result to "0"; NaN and infinity
render null (TryGetIndexNumber returns false, matching "matches no
comparison").
- contracts/cultnet/cultnet.selection.schema.json: "number" is now a string
with the canonical pattern (a defence-in-depth (?!-0$) lookahead beyond
what the door enforces in code), not a JSON number.
- Tests: CultNetCanonicalNumberTests (new) pins IsCanonical/Compare directly,
including the door's five named non-canonical spellings, magnitude vs.
lexicographic ordering, sign, integer-part length, and a long past 2^53
against its float64 rounding. CultNetSelectionEvaluatorTests gains
ValidationRefusesNonCanonicalNumberSpellings (+1, 1.0, 01, 1e3, -0) and its
existing Number literals become canonical strings.
CultDocumentSelectionSurfaceTests gains a dedicated FixtureLeafNumbers
fixture and four tests: the 2^53+1 long rendering exactly, a decimal's
trailing zeros stripped, a double's exponent notation rewritten to
positional digits (1e21, 1e-7), and NaN/Infinity answering false.
Mutation entries (scripts/mutate-dotnet.ps1): four new Caching entries
(integer-through-float64, decimal trailing zeros, exponent expansion,
NaN/Infinity) and five new Networking entries (the door's regex, the door's
explicit "-0" exclusion, the comparator's integer-length check, a
lexicographic-whole-string mutant, and the comparator's sign check) - all
KILLED. The two pre-existing S16 (le/ge boundary) entries had to be
re-anchored: their Old text quoted CompareNumber's previous double-based
`left <= right`/`left >= right` spelling, which no longer exists now that
CompareNumber switches on Compare's int result: `cmp <= 0`/`cmp >= 0`.
Verification: dotnet test - GameCult.Networking.Tests 192/192 (was 183 before
these additions), GameCult.Caching.Tests 192/192 (was 188), GameCult.Mesh.Tests
252/253 (one pre-existing unrelated skip, unaffected by this commit - no Mesh
source touched). Caching mutation run: 10/10 KILLED (6 pre-existing + 4 new),
M0 and final both green. Networking mutation run: 15/15 KILLED (10
pre-existing + 5 new, across CultNetSelectionEvaluator.cs, CultNetDatabase.cs
and CultNetSelection.cs), M0 and final both green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 04:18:22 +02:00
|
|
|
#nullable enable
|
|
|
|
|
using NUnit.Framework;
|
|
|
|
|
|
|
|
|
|
namespace GameCult.Networking.Tests
|
|
|
|
|
{
|
|
|
|
|
// Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers", 2026-09-22): the wire's canonical
|
|
|
|
|
// decimal form - the door's grammar and the comparator that reads it. Pure string-in functions,
|
|
|
|
|
// independent of the cache and the evaluator; CultDocumentSelectionSurfaceTests (GameCult.Caching.Tests)
|
|
|
|
|
// pins the row side's rendering of the same form.
|
|
|
|
|
public sealed class CultNetCanonicalNumberTests
|
|
|
|
|
{
|
|
|
|
|
[TestCase("0")]
|
|
|
|
|
[TestCase("1")]
|
|
|
|
|
[TestCase("-1")]
|
|
|
|
|
[TestCase("10")]
|
|
|
|
|
[TestCase("0.5")]
|
|
|
|
|
[TestCase("-0.5")]
|
|
|
|
|
[TestCase("9007199254740993")]
|
|
|
|
|
[TestCase("123.456")]
|
|
|
|
|
public void IsCanonicalAcceptsCanonicalSpellings(string value) =>
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.IsCanonical(value), Is.True);
|
|
|
|
|
|
|
|
|
|
// The door's named mutants: a leading '+', a trailing fractional zero, a leading integer zero,
|
|
|
|
|
// exponent notation, and negative zero are each refused, never normalised.
|
|
|
|
|
[TestCase("+1")]
|
|
|
|
|
[TestCase("1.0")]
|
|
|
|
|
[TestCase("01")]
|
|
|
|
|
[TestCase("1e3")]
|
|
|
|
|
[TestCase("1E3")]
|
|
|
|
|
[TestCase("-0")]
|
|
|
|
|
[TestCase("")]
|
|
|
|
|
[TestCase(" 1")]
|
|
|
|
|
[TestCase("1.")]
|
|
|
|
|
[TestCase("1..5")]
|
2026-09-22 09:39:36 +02:00
|
|
|
// R-D/Q-J (Soul's whole-cut pass, confirmed): .NET's $ matches immediately before a single
|
|
|
|
|
// trailing newline even without RegexOptions.Multiline, so a naive `$`-anchored pattern accepts
|
|
|
|
|
// "5\n" as canonical - the door must refuse it exactly as it refuses any other non-canonical
|
|
|
|
|
// spelling, byte for byte the same as Rust and the JSON schema's ECMA-262 $.
|
|
|
|
|
[TestCase("5\n")]
|
|
|
|
|
[TestCase("5\r\n")]
|
CultNet selection cut1, commit 2f: the Q-J fix (canonical decimal numbers)
Corrects commit 1's Number field: docs/cultnet-selection-cut.md section 2
"Numbers" (added 2026-09-22, superseding the map's own stale float64 text)
specifies canonical decimal strings per the operator's Q-J ruling, and
commits 0/1 shipped double instead - the Rust Hands caught the conflict
before writing a line and stopped; this is the correction the map's
Progress block calls for before Rust starts.
- CultNetFieldPredicate.Number: double? -> string?. New CultNetCanonicalNumber
(CultNetSelection.cs) is the wire's canonical grammar and comparator:
IsCanonical validates ^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$ and excludes "-0"
(the regex alone matches it); Compare orders by sign, then the integer
part's length, then its digits, then the fraction padded on the right to
equal length - a pure string function, no double anywhere on the path.
ValidateField refuses a non-canonical number at the door
(selection_invalid) rather than normalising it.
- CultNetSelectionEvaluator.CompareNumber now takes two canonical strings and
switches on CultNetCanonicalNumber.Compare's sign instead of comparing
doubles directly.
- CultCache.cs: CultDocumentDescriptor.TryGetIndexNumber and
PersistedMember.NumberOf render a member's exact canonical decimal instead
of a double. An integer renders exact (no 2^53 limit); decimal strips
trailing fractional zeros; float/double use .NET's shortest round-trip
ToString(), rewritten out of exponent notation to positional digits
(ExpandScientificNotation) before the shared CanonicalizeDecimalDigits
strips zeros and collapses a negative-zero result to "0"; NaN and infinity
render null (TryGetIndexNumber returns false, matching "matches no
comparison").
- contracts/cultnet/cultnet.selection.schema.json: "number" is now a string
with the canonical pattern (a defence-in-depth (?!-0$) lookahead beyond
what the door enforces in code), not a JSON number.
- Tests: CultNetCanonicalNumberTests (new) pins IsCanonical/Compare directly,
including the door's five named non-canonical spellings, magnitude vs.
lexicographic ordering, sign, integer-part length, and a long past 2^53
against its float64 rounding. CultNetSelectionEvaluatorTests gains
ValidationRefusesNonCanonicalNumberSpellings (+1, 1.0, 01, 1e3, -0) and its
existing Number literals become canonical strings.
CultDocumentSelectionSurfaceTests gains a dedicated FixtureLeafNumbers
fixture and four tests: the 2^53+1 long rendering exactly, a decimal's
trailing zeros stripped, a double's exponent notation rewritten to
positional digits (1e21, 1e-7), and NaN/Infinity answering false.
Mutation entries (scripts/mutate-dotnet.ps1): four new Caching entries
(integer-through-float64, decimal trailing zeros, exponent expansion,
NaN/Infinity) and five new Networking entries (the door's regex, the door's
explicit "-0" exclusion, the comparator's integer-length check, a
lexicographic-whole-string mutant, and the comparator's sign check) - all
KILLED. The two pre-existing S16 (le/ge boundary) entries had to be
re-anchored: their Old text quoted CompareNumber's previous double-based
`left <= right`/`left >= right` spelling, which no longer exists now that
CompareNumber switches on Compare's int result: `cmp <= 0`/`cmp >= 0`.
Verification: dotnet test - GameCult.Networking.Tests 192/192 (was 183 before
these additions), GameCult.Caching.Tests 192/192 (was 188), GameCult.Mesh.Tests
252/253 (one pre-existing unrelated skip, unaffected by this commit - no Mesh
source touched). Caching mutation run: 10/10 KILLED (6 pre-existing + 4 new),
M0 and final both green. Networking mutation run: 15/15 KILLED (10
pre-existing + 5 new, across CultNetSelectionEvaluator.cs, CultNetDatabase.cs
and CultNetSelection.cs), M0 and final both green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 04:18:22 +02:00
|
|
|
public void IsCanonicalRefusesNonCanonicalSpellings(string value) =>
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.IsCanonical(value), Is.False);
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void IsCanonicalRefusesNull() =>
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.IsCanonical(null), Is.False);
|
|
|
|
|
|
|
|
|
|
// The comparator: sign first, then the integer part's length, then its digits, then the
|
|
|
|
|
// fraction padded on the right - never a lexicographic compare of the whole string, which
|
|
|
|
|
// would rank "10" below "9".
|
|
|
|
|
[Test]
|
|
|
|
|
public void CompareOrdersByMagnitudeNotLexicographically()
|
|
|
|
|
{
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("10", "9"), Is.GreaterThan(0));
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("9", "10"), Is.LessThan(0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void CompareOrdersNegativeBelowPositive()
|
|
|
|
|
{
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("-1", "1"), Is.LessThan(0));
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("1", "-1"), Is.GreaterThan(0));
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("-10", "-2"), Is.LessThan(0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Same leading digit, different length: "100" > "99" even though '1' < '9' as a bare character
|
|
|
|
|
// comparison - this is what a comparator that skips the integer part's length gets wrong.
|
|
|
|
|
[Test]
|
|
|
|
|
public void CompareOrdersByIntegerPartLengthBeforeDigits()
|
|
|
|
|
{
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("100", "99"), Is.GreaterThan(0));
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("99", "100"), Is.LessThan(0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// "1.5" vs "1.45": padded to "50" vs "45" so 1.5 > 1.45. Comparing the raw, unpadded fraction
|
|
|
|
|
// strings would get this wrong ("5" > "45" as characters is true by luck, "45" > "5" as strings
|
|
|
|
|
// sorted by length first is not the rule either) - padding to equal length is the rule.
|
|
|
|
|
[Test]
|
|
|
|
|
public void CompareOrdersFractionsByPaddedDigitsNotRawLength()
|
|
|
|
|
{
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("1.5", "1.45"), Is.GreaterThan(0));
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("1.45", "1.5"), Is.LessThan(0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void CompareTreatsEqualCanonicalStringsAsEqual()
|
|
|
|
|
{
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("5", "5"), Is.EqualTo(0));
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("-0.5", "-0.5"), Is.EqualTo(0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The row-rendering mutant named in section 2 "Numbers": a long past 2^53 (where double loses
|
|
|
|
|
// integer precision) must still compare correctly once rendered. This pins the comparator's
|
|
|
|
|
// side of that guarantee; CultDocumentSelectionSurfaceTests pins the cache's rendering side.
|
|
|
|
|
[Test]
|
|
|
|
|
public void CompareDistinguishesALongPast2Pow53FromItsFloat64Rounding()
|
|
|
|
|
{
|
|
|
|
|
Assert.That(CultNetCanonicalNumber.Compare("9007199254740993", "9007199254740992"), Is.GreaterThan(0));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|