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>
This commit is contained in:
parent
4c33a62b29
commit
e5348d3d57
9 changed files with 459 additions and 31 deletions
|
|
@ -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" } } },
|
||||
|
|
|
|||
|
|
@ -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))),'
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)'
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<T> with a value produces a boxed T (never a boxed Nullable<T>), 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<object, string> Getter { get; set; } = default!;
|
||||
public Func<object, string?> GetterNullable { get; set; } = default!;
|
||||
|
||||
// D8: non-null exactly for the closed CLR numeric set (Nullable<T> unwrapped). Convert.ToDouble
|
||||
// never loses a value CultCache's own supported numeric types can hold.
|
||||
public Func<object, double?> NumberOf { get; set; } = default!;
|
||||
// D8/Q-J (revised 2026-09-22): non-null exactly for the closed CLR numeric set (Nullable<T>
|
||||
// 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<object, string?> 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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Pattern"/>, 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.
|
||||
/// </summary>
|
||||
public static class CultNetCanonicalNumber
|
||||
{
|
||||
/// <summary>
|
||||
/// Forbids leading zeros, a trailing fractional zero or bare point, an explicit '+', exponent
|
||||
/// notation, and whitespace. Does not by itself forbid "-0"; <see cref="IsCanonical"/> does.
|
||||
/// </summary>
|
||||
public const string Pattern = @"^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$";
|
||||
|
||||
private static readonly Regex CanonicalRegex = new(Pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>True for a canonical decimal string; false for null, "-0", or any other spelling.</summary>
|
||||
public static bool IsCanonical(string? value) =>
|
||||
value != null && value != "-0" && CanonicalRegex.IsMatch(value);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="IsCanonical"/> first; this does not
|
||||
/// re-validate.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One predicate over a single declared index alias (docs/cultnet-selection-cut.md, section 2).</summary>
|
||||
[MessagePackObject]
|
||||
public sealed class CultNetFieldPredicate
|
||||
|
|
@ -67,8 +130,8 @@ namespace GameCult.Networking
|
|||
[Key("op")] public string Op { get; set; } = string.Empty;
|
||||
/// <summary>Present iff op = any_of; non-empty.</summary>
|
||||
[Key("values")] public string[]? Values { get; set; }
|
||||
/// <summary>Present iff op is a comparison.</summary>
|
||||
[Key("number")] public double? Number { get; set; }
|
||||
/// <summary>Present iff op is a comparison; a canonical decimal string (Q-J, <see cref="CultNetCanonicalNumber"/>).</summary>
|
||||
[Key("number")] public string? Number { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Identifies one row on the wire by schema and record key.</summary>
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a selection over the full row set: schemas/keys/fields, the hop (cites/cited,
|
||||
|
|
|
|||
|
|
@ -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<FixtureLeafNumbers>();
|
||||
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<FixtureLeafNumbers>();
|
||||
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<FixtureLeafNumbers>();
|
||||
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<FixtureLeafNumbers>();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SelLeafA>().SchemaId, registry.GetRequired<SelLeafB>().SchemaId },
|
||||
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = 10 } }
|
||||
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = "10" } }
|
||||
},
|
||||
asOf: 1);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue