diff --git a/contracts/cultnet/cultnet.selection.schema.json b/contracts/cultnet/cultnet.selection.schema.json index f637c0c1..bf3cb534 100644 --- a/contracts/cultnet/cultnet.selection.schema.json +++ b/contracts/cultnet/cultnet.selection.schema.json @@ -50,7 +50,11 @@ "items": { "type": "string" }, "minItems": 1 }, - "number": { "type": "number" } + "number": { + "type": "string", + "description": "Q-J: a canonical decimal string, never a JSON number. Matches ^-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$ and is not \"-0\"; any other spelling is refused typed at the door (selection_invalid), never normalised.", + "pattern": "^(?!-0$)-?(0|[1-9][0-9]*)(\\.[0-9]*[1-9])?$" + } }, "oneOf": [ { "required": ["values"], "properties": { "op": { "const": "any_of" } } }, diff --git a/scripts/mutate-cultnet-selection-caching.entries.ps1 b/scripts/mutate-cultnet-selection-caching.entries.ps1 index e844c832..96b37703 100644 --- a/scripts/mutate-cultnet-selection-caching.entries.ps1 +++ b/scripts/mutate-cultnet-selection-caching.entries.ps1 @@ -50,5 +50,40 @@ Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues' Old = 'results.Add((reference.Key, valueProperty.GetValue(entry)));' New = 'results.Add((reference.Key, (object?)null));' + }, + # Q-J (docs/cultnet-selection-cut.md section 2 "Numbers", 2026-09-22): the row side's canonical + # decimal rendering. The map named these five mutants explicitly; the fifth ("a row rendered + # through float64") is this campaign's, the other four are the door's (networking entries file). + @{ + Id = 'CACHE-QJ-IntegerFloat64-Revert' + Rule = 'Q-J: an integer renders its exact decimal, never through double - a long past 2^53 must not round.' + Mutant = 'revert' + Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.TryGetIndexNumberRendersALongPast2Pow53ExactlyNotThroughFloat64' + Old = '_ => CanonicalizeDecimalDigits(Convert.ToString(raw, CultureInfo.InvariantCulture)!)' + New = '_ => CanonicalizeDecimalDigits(Convert.ToDouble(raw, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture))' + }, + @{ + Id = 'CACHE-QJ-DecimalTrailingZeros-Revert' + Rule = 'Q-J: a decimal renders with its trailing fractional zeros stripped.' + Mutant = 'revert' + Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.TryGetIndexNumberStripsTrailingZerosFromADecimal' + Old = "fractionPart = fractionPart.TrimEnd('0');" + New = '' + }, + @{ + Id = 'CACHE-QJ-ExponentExpansion-Revert' + Rule = 'Q-J: a double/float that .NET would render in exponent notation is rewritten to positional digits before canonicalizing.' + Mutant = 'revert' + Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits' + Old = 'double d => double.IsNaN(d) || double.IsInfinity(d) ? null : CanonicalizeDecimalDigits(ExpandScientificNotation(d.ToString(CultureInfo.InvariantCulture))),' + New = 'double d => double.IsNaN(d) || double.IsInfinity(d) ? null : CanonicalizeDecimalDigits(d.ToString(CultureInfo.InvariantCulture)),' + }, + @{ + Id = 'CACHE-QJ-NaNInfinity-Revert' + Rule = 'Q-J: a NaN or infinite member matches no comparison - TryGetIndexNumber must answer false, not a garbage rendering of the value.' + Mutant = 'revert' + Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.TryGetIndexNumberReturnsFalseForNaNAndInfinity' + Old = 'double d => double.IsNaN(d) || double.IsInfinity(d) ? null : CanonicalizeDecimalDigits(ExpandScientificNotation(d.ToString(CultureInfo.InvariantCulture))),' + New = 'double d => CanonicalizeDecimalDigits(ExpandScientificNotation(d.ToString(CultureInfo.InvariantCulture))),' } ) diff --git a/scripts/mutate-cultnet-selection-networking.entries.ps1 b/scripts/mutate-cultnet-selection-networking.entries.ps1 index 0b77530d..b8ddb8e1 100644 --- a/scripts/mutate-cultnet-selection-networking.entries.ps1 +++ b/scripts/mutate-cultnet-selection-networking.entries.ps1 @@ -71,8 +71,8 @@ Mutant = 'revert' File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs' Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionEvaluatorTests.EvaluatorComparesNumbersAtTheBoundaryForEachOfTheFourOperators' - Old = 'CultNetSelectionOperator.Le => left <= right,' - New = 'CultNetSelectionOperator.Le => left < right,' + Old = 'CultNetSelectionOperator.Le => cmp <= 0,' + New = 'CultNetSelectionOperator.Le => cmp < 0,' }, @{ Id = 'NET-S16-Ge-Revert' @@ -80,8 +80,8 @@ Mutant = 'revert' File = 'src/GameCult.Networking/CultNetSelectionEvaluator.cs' Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetSelectionEvaluatorTests.EvaluatorComparesNumbersAtTheBoundaryForEachOfTheFourOperators' - Old = 'CultNetSelectionOperator.Ge => left >= right,' - New = 'CultNetSelectionOperator.Ge => left > right,' + Old = 'CultNetSelectionOperator.Ge => cmp >= 0,' + New = 'CultNetSelectionOperator.Ge => cmp > 0,' }, @{ Id = 'NET-S18-Revert' @@ -108,4 +108,54 @@ # filter still returns the same single row. This is a finding about the fixture, not a defended # rule - a second document of a different schema in the same shard is needed to make the # loosening fail, and is not added here (no easier mutant substituted per the Hands brief). + # + # Q-J (docs/cultnet-selection-cut.md section 2 "Numbers", 2026-09-22): the door's canonical-decimal + # grammar (CultNetSelection.cs) and the comparator that reads it (CultNetSelectionEvaluator.cs's + # CompareNumber, via CultNetCanonicalNumber.Compare). CultNetCanonicalNumberTests pins Compare + # directly with more cases than the entries below exercise; every entry here is one the map named. + @{ + Id = 'NET-QJ-Door-Regex-Revert' + Rule = 'Q-J: the door refuses a non-canonical number spelling (+1, 1.0, 01, 1e3) rather than accepting it.' + Mutant = 'revert' + File = 'src/GameCult.Networking/CultNetSelection.cs' + Killer = 'FullyQualifiedName~CultNetSelectionEvaluatorTests.ValidationRefusesNonCanonicalNumberSpellings' + Old = 'if (!CultNetCanonicalNumber.IsCanonical(field.Number))' + New = 'if (false)' + }, + @{ + Id = 'NET-QJ-Door-NegativeZero-Revert' + Rule = 'Q-J: "-0" is excluded even though the regex alone matches it - IsCanonical checks it explicitly.' + Mutant = 'revert' + File = 'src/GameCult.Networking/CultNetSelection.cs' + Killer = 'FullyQualifiedName~CultNetSelectionEvaluatorTests.ValidationRefusesNonCanonicalNumberSpellings' + Old = 'value != null && value != "-0" && CanonicalRegex.IsMatch(value);' + New = 'value != null && CanonicalRegex.IsMatch(value);' + }, + @{ + Id = 'NET-QJ-Compare-IntegerLength-Revert' + Rule = 'Q-J: the comparator checks the integer part''s length before its digits - "100" > "99" even though ''1'' < ''9'' as a bare character.' + Mutant = 'revert' + File = 'src/GameCult.Networking/CultNetSelection.cs' + Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetCanonicalNumberTests.CompareOrdersByIntegerPartLengthBeforeDigits' + Old = " if (integerLeft.Length != integerRight.Length)`n return sign * (integerLeft.Length < integerRight.Length ? -1 : 1);`n`n" + New = '' + }, + @{ + Id = 'NET-QJ-Compare-Lexicographic-Revert' + Rule = 'Q-J: the comparator is not a lexicographic compare of the whole string ("10" must not be less than "9").' + Mutant = 'revert' + File = 'src/GameCult.Networking/CultNetSelection.cs' + Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetCanonicalNumberTests.CompareOrdersByMagnitudeNotLexicographically' + Old = 'var (negativeLeft, integerLeft, fractionLeft) = Decompose(left);' + New = 'return string.CompareOrdinal(left, right); var (negativeLeft, integerLeft, fractionLeft) = Decompose(left);' + }, + @{ + Id = 'NET-QJ-Compare-Sign-Revert' + Rule = 'Q-J: the comparator orders a negative value below a positive one of the same magnitude.' + Mutant = 'revert' + File = 'src/GameCult.Networking/CultNetSelection.cs' + Killer = 'FullyQualifiedName=GameCult.Networking.Tests.CultNetCanonicalNumberTests.CompareOrdersNegativeBelowPositive' + Old = 'if (negativeLeft != negativeRight)' + New = 'if (false)' + } ) diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 978c8822..774fba9e 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -230,7 +230,7 @@ namespace GameCult.Caching return false; } - public bool TryGetIndexNumber(object document, string alias, out double value) + public bool TryGetIndexNumber(object document, string alias, out string? value) { if (document == null) throw new ArgumentNullException(nameof(document)); if (!string.IsNullOrEmpty(alias)) @@ -240,9 +240,9 @@ namespace GameCult.Caching if (string.Equals(member.IndexAlias, alias, StringComparison.Ordinal) && member.IsNumeric) { var number = member.NumberOf(document); - if (number.HasValue) + if (number != null) { - value = number.Value; + value = number; return true; } @@ -251,7 +251,7 @@ namespace GameCult.Caching } } - value = default; + value = null; return false; } @@ -1223,6 +1223,73 @@ namespace GameCult.Caching return NumericClrTypes.Contains(underlying); } + // Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers"): a row's exact canonical decimal, + // never a double - a long past 2^53 must not lose precision passing through this path. Boxing a + // Nullable with a value produces a boxed T (never a boxed Nullable), so raw is always one + // of the closed CLR numeric set's non-nullable types here. NaN and infinity render as null: + // "a NaN or infinite member matches no comparison." + private static string? RenderCanonicalNumber(object raw) => raw switch + { + float f => float.IsNaN(f) || float.IsInfinity(f) ? null : CanonicalizeDecimalDigits(ExpandScientificNotation(f.ToString(CultureInfo.InvariantCulture))), + double d => double.IsNaN(d) || double.IsInfinity(d) ? null : CanonicalizeDecimalDigits(ExpandScientificNotation(d.ToString(CultureInfo.InvariantCulture))), + decimal m => CanonicalizeDecimalDigits(m.ToString(CultureInfo.InvariantCulture)), + _ => CanonicalizeDecimalDigits(Convert.ToString(raw, CultureInfo.InvariantCulture)!) + }; + + // double/float's default ToString(CultureInfo.InvariantCulture) is .NET's shortest round-trip + // form since .NET Core 3.0, but it uses exponent notation outside a middle magnitude range + // ("1E+20", "1.5E-05"). Rewritten to positional digits before CanonicalizeDecimalDigits runs, so + // the wire never carries an exponent (S16's float64-rendering mutant: a long of 2^53+1 must + // compare greater than "9007199254740992", which a double-precision render would fail). + private static string ExpandScientificNotation(string value) + { + var eIndex = value.IndexOfAny(ExponentMarkers); + if (eIndex < 0) return value; + + var negative = value.Length > 0 && value[0] == '-'; + var unsigned = negative ? value.Substring(1) : value; + eIndex = unsigned.IndexOfAny(ExponentMarkers); + var mantissa = unsigned.Substring(0, eIndex); + var exponent = int.Parse(unsigned.Substring(eIndex + 1), NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture); + + var dot = mantissa.IndexOf('.'); + var digits = dot < 0 ? mantissa : mantissa.Remove(dot, 1); + var pointPosition = (dot < 0 ? digits.Length : dot) + exponent; + + string expanded; + if (pointPosition <= 0) + expanded = "0." + new string('0', -pointPosition) + digits; + else if (pointPosition >= digits.Length) + expanded = digits + new string('0', pointPosition - digits.Length); + else + expanded = digits.Substring(0, pointPosition) + "." + digits.Substring(pointPosition); + + return negative ? "-" + expanded : expanded; + } + + private static readonly char[] ExponentMarkers = { 'E', 'e' }; + + // The one canonicalizer every rendered number passes through: strips leading integer zeros and + // trailing fractional zeros, and collapses a negative value that reduces to zero into "0" - the + // door's regex forbids all three spellings on the way in (Q-J), and this is why the row side + // never produces one on the way out. + private static string CanonicalizeDecimalDigits(string signedPositional) + { + var negative = signedPositional.Length > 0 && signedPositional[0] == '-'; + var unsigned = negative ? signedPositional.Substring(1) : signedPositional; + var dot = unsigned.IndexOf('.'); + var integerPart = dot < 0 ? unsigned : unsigned.Substring(0, dot); + var fractionPart = dot < 0 ? string.Empty : unsigned.Substring(dot + 1); + + integerPart = integerPart.TrimStart('0'); + if (integerPart.Length == 0) integerPart = "0"; + fractionPart = fractionPart.TrimEnd('0'); + + var isZero = integerPart == "0" && fractionPart.Length == 0; + var result = fractionPart.Length == 0 ? integerPart : integerPart + "." + fractionPart; + return negative && !isZero ? "-" + result : result; + } + private static string DuplicateIndexAliasMessage(string documentTypeName, string first, string second, string alias) => $"Cult document {documentTypeName} members {first} and {second} both declare index alias \"{alias}\"; an index alias must name one member."; @@ -1305,9 +1372,11 @@ namespace GameCult.Caching public Func Getter { get; set; } = default!; public Func GetterNullable { get; set; } = default!; - // D8: non-null exactly for the closed CLR numeric set (Nullable unwrapped). Convert.ToDouble - // never loses a value CultCache's own supported numeric types can hold. - public Func NumberOf { get; set; } = default!; + // D8/Q-J (revised 2026-09-22): non-null exactly for the closed CLR numeric set (Nullable + // unwrapped), rendered as the member's exact canonical decimal string - never a double, so + // no CLR numeric type's range or precision is lost rendering it. docs/cultnet-selection-cut.md + // section 2 "Numbers". + public Func NumberOf { get; set; } = default!; // D11: null for a non-reference member; otherwise yields (target key, payload) per declared // shape - one entry for a bare reference, one per element for a many-enumerable, one per key @@ -1343,7 +1412,7 @@ namespace GameCult.Caching NumberOf = document => { var raw = getValue(document); - return raw == null ? null : (double?)Convert.ToDouble(raw, CultureInfo.InvariantCulture); + return raw == null ? null : RenderCanonicalNumber(raw); }, ReferenceEnumerator = isReference ? BuildReferenceEnumerator(memberType, many, getValue) : null }; diff --git a/src/GameCult.Networking/CultNetSelection.cs b/src/GameCult.Networking/CultNetSelection.cs index 994e5e9c..db533607 100644 --- a/src/GameCult.Networking/CultNetSelection.cs +++ b/src/GameCult.Networking/CultNetSelection.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using GameCult.Caching; using MessagePack; @@ -57,6 +58,68 @@ namespace GameCult.Networking public static bool IsComparison(this CultNetSelectionOperator op) => op != CultNetSelectionOperator.AnyOf; } + /// + /// The wire's canonical decimal form (Q-J, docs/cultnet-selection-cut.md section 2 "Numbers"): one + /// spelling per value, so the cursor digest and the cross-runtime parity vectors need no numeric + /// parser and no precision limit. A comparison number that does not match , or + /// that spells zero with a leading minus, is refused at the door rather than normalised - a + /// non-canonical spelling of an equal value never reaches the evaluator. + /// + public static class CultNetCanonicalNumber + { + /// + /// Forbids leading zeros, a trailing fractional zero or bare point, an explicit '+', exponent + /// notation, and whitespace. Does not by itself forbid "-0"; does. + /// + public const string Pattern = @"^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$"; + + private static readonly Regex CanonicalRegex = new(Pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// True for a canonical decimal string; false for null, "-0", or any other spelling. + public static bool IsCanonical(string? value) => + value != null && value != "-0" && CanonicalRegex.IsMatch(value); + + /// + /// Compares two canonical decimal strings by sign, then the integer part's length, then its + /// digits, then the fraction digits padded on the right to equal length - a pure string + /// comparison, never a numeric parse, so no CLR type's range limits what a comparison can hold. + /// Callers must validate both operands with first; this does not + /// re-validate. + /// + public static int Compare(string left, string right) + { + var (negativeLeft, integerLeft, fractionLeft) = Decompose(left); + var (negativeRight, integerRight, fractionRight) = Decompose(right); + + if (negativeLeft != negativeRight) + return negativeLeft ? -1 : 1; + var sign = negativeLeft ? -1 : 1; + + if (integerLeft.Length != integerRight.Length) + return sign * (integerLeft.Length < integerRight.Length ? -1 : 1); + + var integerCompare = string.CompareOrdinal(integerLeft, integerRight); + if (integerCompare != 0) + return sign * Math.Sign(integerCompare); + + var fractionLength = Math.Max(fractionLeft.Length, fractionRight.Length); + var fractionCompare = string.CompareOrdinal( + fractionLeft.PadRight(fractionLength, '0'), + fractionRight.PadRight(fractionLength, '0')); + return sign * Math.Sign(fractionCompare); + } + + private static (bool Negative, string Integer, string Fraction) Decompose(string canonical) + { + var negative = canonical.Length > 0 && canonical[0] == '-'; + var unsigned = negative ? canonical.Substring(1) : canonical; + var dot = unsigned.IndexOf('.'); + return dot < 0 + ? (negative, unsigned, string.Empty) + : (negative, unsigned.Substring(0, dot), unsigned.Substring(dot + 1)); + } + } + /// One predicate over a single declared index alias (docs/cultnet-selection-cut.md, section 2). [MessagePackObject] public sealed class CultNetFieldPredicate @@ -67,8 +130,8 @@ namespace GameCult.Networking [Key("op")] public string Op { get; set; } = string.Empty; /// Present iff op = any_of; non-empty. [Key("values")] public string[]? Values { get; set; } - /// Present iff op is a comparison. - [Key("number")] public double? Number { get; set; } + /// Present iff op is a comparison; a canonical decimal string (Q-J, ). + [Key("number")] public string? Number { get; set; } } /// Identifies one row on the wire by schema and record key. @@ -280,13 +343,15 @@ namespace GameCult.Networking { if (field.Values == null || field.Values.Length == 0) throw new CultNetSelectionInvalidException($"{prefix}.values", null, $"{prefix}.values must be non-empty for op any_of."); - if (field.Number.HasValue) + if (field.Number != null) throw new CultNetSelectionInvalidException($"{prefix}.number", null, $"{prefix} carries both values and number; any_of takes only values."); } else { - if (!field.Number.HasValue) + if (field.Number == null) throw new CultNetSelectionInvalidException($"{prefix}.number", null, $"{prefix}.number is required for op {field.Op}."); + if (!CultNetCanonicalNumber.IsCanonical(field.Number)) + throw new CultNetSelectionInvalidException($"{prefix}.number", field.Number, $"{prefix}.number \"{field.Number}\" is not a canonical decimal string (Q-J): it must match {CultNetCanonicalNumber.Pattern} and not be \"-0\"."); if (field.Values != null) throw new CultNetSelectionInvalidException($"{prefix}.values", null, $"{prefix} carries both values and number; a comparison takes only number."); } diff --git a/src/GameCult.Networking/CultNetSelectionEvaluator.cs b/src/GameCult.Networking/CultNetSelectionEvaluator.cs index b87be175..c2062033 100644 --- a/src/GameCult.Networking/CultNetSelectionEvaluator.cs +++ b/src/GameCult.Networking/CultNetSelectionEvaluator.cs @@ -100,9 +100,9 @@ namespace GameCult.Networking } else { - if (!descriptor.TryGetIndexNumber(document, field.Index, out var number) || !field.Number.HasValue) + if (!descriptor.TryGetIndexNumber(document, field.Index, out var number) || field.Number == null) return false; - if (!CompareNumber(number, op, field.Number.Value)) + if (!CompareNumber(number, op, field.Number)) return false; } } @@ -110,14 +110,22 @@ namespace GameCult.Networking return true; } - private static bool CompareNumber(double left, CultNetSelectionOperator op, double right) => op switch + // Q-J: both operands are canonical decimal strings by the time they reach here (the row side + // renders through RenderCanonicalNumber, the wire side is validated at the door), so the + // comparison is CultNetCanonicalNumber.Compare's pure string function - no double anywhere on + // this path (docs/cultnet-selection-cut.md section 2 "Numbers"). + private static bool CompareNumber(string left, CultNetSelectionOperator op, string right) { - CultNetSelectionOperator.Lt => left < right, - CultNetSelectionOperator.Le => left <= right, - CultNetSelectionOperator.Ge => left >= right, - CultNetSelectionOperator.Gt => left > right, - _ => throw new ArgumentOutOfRangeException(nameof(op)) - }; + var cmp = CultNetCanonicalNumber.Compare(left, right); + return op switch + { + CultNetSelectionOperator.Lt => cmp < 0, + CultNetSelectionOperator.Le => cmp <= 0, + CultNetSelectionOperator.Ge => cmp >= 0, + CultNetSelectionOperator.Gt => cmp > 0, + _ => throw new ArgumentOutOfRangeException(nameof(op)) + }; + } /// /// Evaluates a selection over the full row set: schemas/keys/fields, the hop (cites/cited, diff --git a/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs index 7d5bb563..12744bee 100644 --- a/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs +++ b/tests/GameCult.Caching.Tests/CultDocumentSelectionSurfaceTests.cs @@ -118,7 +118,7 @@ namespace GameCult.Caching.Tests Assert.That(leafA.TryGetIndexValue(documentA, "mass", out var text), Is.True); Assert.That(text, Is.EqualTo("3.5")); Assert.That(leafA.TryGetIndexNumber(documentA, "mass", out var number), Is.True); - Assert.That(number, Is.EqualTo(3.5d)); + Assert.That(number, Is.EqualTo("3.5")); } [Test] @@ -142,7 +142,7 @@ namespace GameCult.Caching.Tests Assert.That(weight.IsNumeric, Is.True); var document = new FixtureLeafB { Weight = 4.25m }; Assert.That(leafB.TryGetIndexNumber(document, "weight", out var number), Is.True); - Assert.That(number, Is.EqualTo(4.25d)); + Assert.That(number, Is.EqualTo("4.25")); } [Test] @@ -155,6 +155,63 @@ namespace GameCult.Caching.Tests Assert.That(name.IsNumeric, Is.False); } + // Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers", 2026-09-22): the row side renders a + // member's exact canonical decimal - never a double. A long past 2^53 (where double loses + // integer precision) is the named boundary: rendering it through float64 would silently produce + // "9007199254740992" for 9007199254740993, and the two would then compare equal on the wire. + [Test] + public void TryGetIndexNumberRendersALongPast2Pow53ExactlyNotThroughFloat64() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) }); + var leaf = registry.GetRequired(); + var document = new FixtureLeafNumbers { BigInteger = 9007199254740993L }; + + Assert.That(leaf.TryGetIndexNumber(document, "big_integer", out var number), Is.True); + Assert.That(number, Is.EqualTo("9007199254740993")); + } + + [Test] + public void TryGetIndexNumberStripsTrailingZerosFromADecimal() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) }); + var leaf = registry.GetRequired(); + var document = new FixtureLeafNumbers { TrimmedDecimal = 13.500m }; + + Assert.That(leaf.TryGetIndexNumber(document, "trimmed_decimal", out var number), Is.True); + Assert.That(number, Is.EqualTo("13.5")); + } + + // The shortest round-trip form .NET renders for a double/float outside a middle magnitude range + // uses exponent notation ("1E+21", "1E-07"); the canonical wire form never does (Q-J). + [Test] + public void TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) }); + var leaf = registry.GetRequired(); + var wide = new FixtureLeafNumbers { WideDouble = 1e21 }; + var tiny = new FixtureLeafNumbers { TinyDouble = 1e-7 }; + + Assert.That(leaf.TryGetIndexNumber(wide, "wide_double", out var wideNumber), Is.True); + Assert.That(wideNumber, Does.Not.Contain("E")); + Assert.That(wideNumber, Is.EqualTo("1000000000000000000000")); + + Assert.That(leaf.TryGetIndexNumber(tiny, "tiny_double", out var tinyNumber), Is.True); + Assert.That(tinyNumber, Does.Not.Contain("E")); + Assert.That(tinyNumber, Is.EqualTo("0.0000001")); + } + + [Test] + public void TryGetIndexNumberReturnsFalseForNaNAndInfinity() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) }); + var leaf = registry.GetRequired(); + var nan = new FixtureLeafNumbers { WideDouble = double.NaN }; + var infinite = new FixtureLeafNumbers { WideDouble = double.PositiveInfinity }; + + Assert.That(leaf.TryGetIndexNumber(nan, "wide_double", out _), Is.False); + Assert.That(leaf.TryGetIndexNumber(infinite, "wide_double", out _), Is.False); + } + // D10 and D11 registration refusals: emitted into a dynamic assembly, invisible to registry // discovery by attribute scan, exactly as DocumentShapeTests does for every other shape refusal. private static readonly ModuleBuilder Rejected = AssemblyBuilder @@ -250,6 +307,34 @@ namespace GameCult.Caching.Tests public decimal? Weight; } + // Q-J: a fixture dedicated to the row-side canonical rendering rules, independent of the + // abstract-middle/inheritance fixtures above so it can carry a long, a decimal and two doubles + // without disturbing the schemas other tests in this file pin. + [CultDocument("selection.fixture.leaf_numbers", "selection.fixture.leaf_numbers.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class FixtureLeafNumbers + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + [CultIndex("big_integer")] + public long BigInteger; + + [Key(2)] + [CultIndex("trimmed_decimal")] + public decimal TrimmedDecimal; + + [Key(3)] + [CultIndex("wide_double")] + public double WideDouble; + + [Key(4)] + [CultIndex("tiny_double")] + public double TinyDouble; + } + [CultDocument("selection.fixture.citer", "selection.fixture.citer.v1")] [MessagePackObject(AllowPrivate = true)] public sealed class FixtureCiter diff --git a/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs b/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs new file mode 100644 index 00000000..d179acef --- /dev/null +++ b/tests/GameCult.Networking.Tests/CultNetCanonicalNumberTests.cs @@ -0,0 +1,95 @@ +#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")] + 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)); + } + } +} diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs index de369014..2567e3e1 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs @@ -51,6 +51,23 @@ namespace GameCult.Networking.Tests Assert.That(ex!.Field, Is.EqualTo(expectedField)); } + // Q-J (docs/cultnet-selection-cut.md, section 2 "Numbers", 2026-09-22): the door refuses every + // non-canonical spelling of a comparison number, rather than normalising it - a leading '+', a + // trailing fractional zero, a leading integer zero, exponent notation, and negative zero. + [TestCase("+1")] + [TestCase("1.0")] + [TestCase("01")] + [TestCase("1e3")] + [TestCase("-0")] + public void ValidationRefusesNonCanonicalNumberSpellings(string spelling) + { + var descriptors = Registry().AllDescriptors.ToArray(); + AssertInvalid( + new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = spelling } } }, + descriptors, + "fields[0].number"); + } + // S2: fields conjoin; any_of matches on membership. [Test] public void EvaluatorConjoinsAnyOfPredicatesOverDeclaredIndexes() @@ -64,7 +81,7 @@ namespace GameCult.Networking.Tests Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } }, - new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = 3 } + new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "3" } } }; @@ -217,7 +234,7 @@ namespace GameCult.Networking.Tests string[] Matches(string op) => CultNetSelectionEvaluator.Select( registry, rows, - new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = op, Number = 5 } } }, + new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = op, Number = "5" } } }, asOf: 1).Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal).ToArray(); Assert.That(Matches("lt"), Is.EqualTo(new[] { "lo" })); @@ -240,7 +257,7 @@ namespace GameCult.Networking.Tests new CultNetSelection { Schemas = new[] { registry.GetRequired().SchemaId, registry.GetRequired().SchemaId }, - Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = 10 } } + Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = "10" } } }, asOf: 1);