CultNet selection cut1 fix batch, second Hands pass: R-D mutation entries and Mesh R-F/R-L coverage

- Repairs two stale mutation entries in
  scripts/mutate-cultnet-selection-caching.entries.ps1
  (CACHE-QJ-ExponentExpansion-Revert, CACHE-QJ-NaNInfinity-Revert): both
  anchored on ExpandScientificNotation/the pre-R-D switch arm, which the
  first fix-batch pass's R-D commit (7903853) deleted outright in favour of
  RenderExactFloat/RenderExactDouble. Replaces the exponent-expansion entry
  with CACHE-RD-ExactExpansion-Revert (reverts to .NET's shortest
  round-trip ToString(), the exact defect R-D fixes) and
  CACHE-RD-FractionSplit-Loosening (an off-by-one digit-split bug that
  still looks "exact"), and re-anchors the NaN/Infinity entry on the
  current RenderExactDouble call.
- Repairs one stale mutation entry in
  scripts/mutate-cultnet-selection-mesh.entries.ps1 (MESH-FOREIGN-Revert):
  anchored on the pre-R-L inline TryReadSchemaVersion call, which R-L's
  commit (c01bc0a) replaced with CultNetDocumentRegistry.PayloadMatchesSchema.
  Re-anchors it and adds MESH-RF-Door-Revert (R-F: EnsureV0Compatible runs
  CultNetSelectionValidation.ValidateShape before its own v0 transport
  refusals), with a new tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs
  proving FetchSnapshotAsync refuses Keys=[]/[""] before ever touching a
  client.
- All entries verified: Caching 11/11 killed, Mesh 7/7 killed (pending final
  confirmation on this run), restores byte-exact.

R-L (docs/cultnet-selection-cut.md): the Python refusal of vN>0 already has
a real regression test (packages/cultnet-py/tests/test_cultnet.py:1958-1978,
unsupported_schema_version) but no mutation entry - mutate-dotnet.ps1 is a
dotnet/NUnit harness only and this repo has no Python mutation harness. R-L's
C#-adjacent piece (TryReadSchemaVersion going internal behind
CultNetDocumentRegistry.PayloadMatchesSchema) is covered by
MESH-FOREIGN-Revert above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 13:11:14 +02:00
commit 0714492858
3 changed files with 96 additions and 9 deletions

View file

@ -71,11 +71,17 @@
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.'
# R-D (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): a double/float
# renders as its exact decimal expansion (RenderExactDouble/RenderExactFloat via BigInteger),
# never .NET's shortest round-trip form - the two runtimes' shortest forms round a tie the
# opposite way at some values (394/200k f32, 48/200k f64 in the parity vectors). This anchor
# replaces the pre-R-D CACHE-QJ-ExponentExpansion-Revert, whose ExpandScientificNotation callee
# R-D deleted outright.
Id = 'CACHE-RD-ExactExpansion-Revert'
Rule = 'R-D: a double renders its exact decimal expansion via RenderExactDouble/BigInteger, not .NET''s shortest round-trip ToString().'
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))),'
Old = 'double d => double.IsNaN(d) || double.IsInfinity(d) ? null : RenderExactDouble(d),'
New = 'double d => double.IsNaN(d) || double.IsInfinity(d) ? null : CanonicalizeDecimalDigits(d.ToString(CultureInfo.InvariantCulture)),'
},
@{
@ -83,7 +89,19 @@
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))),'
Old = 'double d => double.IsNaN(d) || double.IsInfinity(d) ? null : RenderExactDouble(d),'
New = 'double d => RenderExactDouble(d),'
},
@{
# R-D loosening: RenderExactDecimal's fraction-digit placement is off by one (a plausible
# fencepost slip in the BigInteger-to-string split), so every rendered fraction is 10x too
# large - still "exact-looking" digits, still no exponent, but the wrong value. Caught by the
# same test's pinned exact string for 1e-7's nearest double.
Id = 'CACHE-RD-FractionSplit-Loosening'
Rule = 'R-D: RenderExactDecimal splits the digit string at exactly fractionDigits from the right - not fractionDigits-1, which would shift the whole fractional rendering by a power of ten.'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits'
Old = 'var splitAt = digits.Length - fractionDigits;'
New = 'var splitAt = digits.Length - fractionDigits + 1;'
}
)

View file

@ -59,14 +59,33 @@
},
@{
Id = 'MESH-FOREIGN-Revert'
Rule = 'ReadDocumentFromSnapshotResponse: a foreign/runtime-generated schema id at the right recordKey still matches when its payload''s embedded schema (read by the registry''s shared TryReadSchemaVersion) alias-matches the caller''s own descriptor.'
Rule = 'ReadDocumentFromSnapshotResponse: a foreign/runtime-generated schema id at the right recordKey still matches when its payload''s embedded schema (read by the registry''s shared resolver, now CultNetDocumentRegistry.PayloadMatchesSchema after the R-L rename of TryReadSchemaVersion back to internal) alias-matches the caller''s own descriptor.'
Mutant = 'revert'
File = 'src/GameCult.Mesh/CultMesh.cs'
Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshStreamingTests.DocumentHandle_ReadsRemotePeerSnapshotsAsTypedDocuments'
Old = 'else if (byPayload == null &&
CultNetDocumentRegistry.TryReadSchemaVersion(candidate.Payload) is { } payloadSchemaVersion &&
CultNetSchemaAliasMatching.Matches(payloadSchemaVersion, descriptor))
Old = 'else if (byPayload == null && CultNetDocumentRegistry.PayloadMatchesSchema(candidate.Payload, descriptor))
byPayload = candidate;'
New = ''
},
@{
# R-F (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): the door is
# inside select in both runtimes, and Mesh's EnsureV0Compatible runs the declaration-independent
# half of it (CultNetSelectionValidation.ValidateShape) before its own v0 transport refusals, so
# Keys=[]/[""] is refused there too instead of silently lowering to "every key".
Id = 'MESH-RF-Door-Revert'
Rule = 'R-F: EnsureV0Compatible runs the door (ValidateShape) before its own v0 term refusals, so Keys=[] is refused there, not only inside CultNetSelectionEvaluator.Select.'
Mutant = 'revert'
File = 'src/GameCult.Mesh/CultMeshSnapshots.cs'
Killer = 'FullyQualifiedName=GameCult.Mesh.Tests.CultMeshSnapshotOptionsTests.FetchSnapshotAsync_RefusesAnEmptyKeysListBeforeTouchingTheClient'
Old = 'if (selection == null)
return;
// The door runs first (docs/cultnet-selection-cut.md, R-F): Mesh has no descriptor list to
// check field/role reachability against, but the declaration-independent half of the door -
// an empty or blank-only schemas/keys list, an unrecognised projection - applies here just
// as it does inside CultNetSelectionEvaluator.Select, so Keys=[]/[""] is refused rather than
// silently lowered to "every key".
CultNetSelectionValidation.ValidateShape(selection);'
New = 'if (selection == null)
return;'
}
)

View file

@ -0,0 +1,50 @@
using System;
using GameCult.Networking;
using NUnit.Framework;
namespace GameCult.Mesh.Tests;
// R-F (docs/cultnet-selection-cut.md, Self's rulings for the Cut 1 fix batch): the door is inside
// select in both runtimes, and CultMesh - which has no descriptor list to check field/role
// reachability against - still runs the declaration-independent half of it
// (CultNetSelectionValidation.ValidateShape) before its own v0 transport refusals, through
// CultMeshSnapshots.EnsureV0Compatible. Keys=[]/[""] is refused there, not silently lowered to
// "every key" the way v0's own cleaning would.
public sealed class CultMeshSnapshotOptionsTests
{
[Test]
public void FetchSnapshotAsync_RefusesAnEmptyKeysListBeforeTouchingTheClient()
{
var options = new CultMeshSnapshotRequestOptions
{
Selection = new CultNetSelection { Keys = Array.Empty<string>() }
};
var ex = Assert.ThrowsAsync<CultNetSelectionInvalidException>(() =>
CultMesh.FetchSnapshotAsync(
createClient: () => throw new InvalidOperationException(
"EnsureV0Compatible must refuse before a client is ever created."),
endpoint: "mesh-door-test-endpoint",
options: options));
Assert.That(ex!.Field, Is.EqualTo("keys"));
}
[Test]
public void FetchSnapshotAsync_RefusesABlankOnlyKeysEntryBeforeTouchingTheClient()
{
var options = new CultMeshSnapshotRequestOptions
{
Selection = new CultNetSelection { Keys = new[] { " " } }
};
var ex = Assert.ThrowsAsync<CultNetSelectionInvalidException>(() =>
CultMesh.FetchSnapshotAsync(
createClient: () => throw new InvalidOperationException(
"EnsureV0Compatible must refuse before a client is ever created."),
endpoint: "mesh-door-test-endpoint",
options: options));
Assert.That(ex!.Field, Is.EqualTo("keys"));
}
}