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:
Meta Vens 2026-09-22 04:18:22 +02:00
commit e5348d3d57
9 changed files with 459 additions and 31 deletions

View file

@ -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" } } },

View file

@ -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))),'
}
)

View file

@ -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)'
}
)

View file

@ -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
};

View file

@ -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.");
}

View file

@ -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,

View file

@ -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

View file

@ -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));
}
}
}

View file

@ -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);