From 07144928586536deb1e6355de4c69f507093c8e5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 13:11:14 +0200 Subject: [PATCH] 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 --- ...tate-cultnet-selection-caching.entries.ps1 | 28 +++++++++-- .../mutate-cultnet-selection-mesh.entries.ps1 | 27 ++++++++-- .../CultMeshSnapshotOptionsTests.cs | 50 +++++++++++++++++++ 3 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs diff --git a/scripts/mutate-cultnet-selection-caching.entries.ps1 b/scripts/mutate-cultnet-selection-caching.entries.ps1 index 96b37703..53d75601 100644 --- a/scripts/mutate-cultnet-selection-caching.entries.ps1 +++ b/scripts/mutate-cultnet-selection-caching.entries.ps1 @@ -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;' } ) diff --git a/scripts/mutate-cultnet-selection-mesh.entries.ps1 b/scripts/mutate-cultnet-selection-mesh.entries.ps1 index 765b23ee..23363195 100644 --- a/scripts/mutate-cultnet-selection-mesh.entries.ps1 +++ b/scripts/mutate-cultnet-selection-mesh.entries.ps1 @@ -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;' } ) diff --git a/tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs new file mode 100644 index 00000000..549f4a1b --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshSnapshotOptionsTests.cs @@ -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() } + }; + + var ex = Assert.ThrowsAsync(() => + 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(() => + 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")); + } +}