R-Q3, R-R: v0 lowering preserves v0's pre-cut answers exactly
CultNetV0SelectionLowering.Lower collapsed a non-null-but-empty filter list to
null ("no filter"). That corrupted two pre-cut v0 behaviours the same
function feeds:
- CultNetDatabase.CreateShardSnapshotResponse and
CultNetDocumentRegistry.CreateRawSnapshotResponse (b3d9cf7) both test
`filter?.RecordKeys != null`, not a length, so `recordKeys: []` always
answered empty. Lowering the empty list to null made it answer every row
instead. This corrects the ruling from the commit-2 fix batch that lowered
an empty v0 list to "no filter".
- CultMesh.ResolveDefaultSelection used the same collapse to decide whether an
explicit empty recordKeys should still fall back to the document type's own
schema default; under the corrected Lower it must not.
Lower(null) still means "no filter"; Lower(non-null) is always a real
(possibly empty) array. The evaluator already treats a non-null empty Keys
array as "match nothing" (CultNetSelectionEvaluator.MatchesSchemaKeysFields);
only the v0 lowering was erasing that signal before selections reached it.
Also restores the pre-cut requested-key order for
CultNetDocumentRegistry.CreateRawSnapshotResponse: pre-cut, requested record
keys were iterated in the order the caller sent them (a HashSet<string> built
from the array, which enumerates in insertion order); the new shared
evaluator's constant ordinal fell back to schema-and-key sort order instead.
ordinalOf now ranks rows by their position in the requested key list when one
was given.
Rewrote CultMeshStreamingTests.FetchDocumentsAsync_WithExplicitEmptyRecordKeys
(renamed ...AnswersEmpty) to assert the corrected behaviour instead of the
ruling it used to pin. Added
CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty,
CultNetDocumentRegistry_RawSnapshot_KeepsRequestedRecordKeyOrder and
CultNetV0SelectionLowering_Lower_DistinguishesNullFromExplicitEmpty to
NetworkingTests.cs.
Not done in this commit: CultNetDatabase.CreateShardSnapshotResponse (the
shard-replication snapshot path) still orders by the evaluator's default
schema-and-key tiebreak. Its own pre-cut order was raw cache iteration order,
which the new evaluator has no equivalent for and shard replication does not
appear to depend on row order; flagged as a discrepancy rather than changed
without a test that would pin the "right" order. The side-by-side b3d9cf7 vs
HEAD comparison run the spec asks for was not executed in this pass; the
pre-cut behaviour above comes from reading CultNetDatabase.cs and
CultNetDocumentRegistry.cs at b3d9cf7 directly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
8d7bee9346
commit
5e33d81e25
5 changed files with 108 additions and 22 deletions
|
|
@ -857,9 +857,11 @@ namespace GameCult.Mesh
|
|||
|
||||
/// <summary>
|
||||
/// Resolves one selection for a typed convenience call: explicit <paramref name="schemaIds"/>
|
||||
/// win; otherwise an explicit, non-empty <paramref name="recordKeys"/> means no schema filter;
|
||||
/// otherwise the selection defaults to the document type's own schema. The one owner of that
|
||||
/// rule (docs/cultnet-selection-cut.md, section 4).
|
||||
/// win; otherwise an explicit <paramref name="recordKeys"/> - including one that lowers to an
|
||||
/// empty array - means no schema filter; otherwise the selection defaults to the document
|
||||
/// type's own schema. R-R (2026-09-22): an empty <paramref name="recordKeys"/> is a real filter
|
||||
/// (answers nothing), not "no filter", so it must not fall through to the schema default
|
||||
/// either. The one owner of that rule (docs/cultnet-selection-cut.md, section 4).
|
||||
/// </summary>
|
||||
internal static CultNetSelection ResolveDefaultSelection(
|
||||
IReadOnlyList<string>? schemaIds,
|
||||
|
|
|
|||
|
|
@ -283,14 +283,32 @@ namespace GameCult.Networking
|
|||
CultNetDocumentMessageOptions? options = null)
|
||||
{
|
||||
if (cache == null) throw new ArgumentNullException(nameof(cache));
|
||||
var lowKeys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys);
|
||||
var selection = new CultNetSelection
|
||||
{
|
||||
Schemas = CultNetV0SelectionLowering.Lower(filter?.SchemaIds),
|
||||
Keys = CultNetV0SelectionLowering.Lower(filter?.RecordKeys),
|
||||
Keys = lowKeys,
|
||||
Projection = CultNetSelectionProjections.Document
|
||||
};
|
||||
|
||||
var page = SelectAll(cache, selection, ordinalOf: static (_, _) => 0, asOf: 0, options);
|
||||
// R-R: v0's pre-cut CreateRawSnapshotResponse (b3d9cf7) iterated the requested record keys
|
||||
// in the order the caller sent them (a HashSet<string> built from the array, enumerated in
|
||||
// insertion order). The evaluator's own tiebreak (schema then key) only applies when the
|
||||
// caller did not name keys.
|
||||
Func<string, CultRecordKey, long> ordinalOf;
|
||||
if (lowKeys is { Length: > 0 })
|
||||
{
|
||||
var order = new Dictionary<string, long>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < lowKeys.Length; i++)
|
||||
order.TryAdd(lowKeys[i], i);
|
||||
ordinalOf = (_, key) => order.TryGetValue(key.Value, out var index) ? index : lowKeys.Length;
|
||||
}
|
||||
else
|
||||
{
|
||||
ordinalOf = static (_, _) => 0;
|
||||
}
|
||||
|
||||
var page = SelectAll(cache, selection, ordinalOf, asOf: 0, options);
|
||||
|
||||
return new CultNetSnapshotResponseRawMessage
|
||||
{
|
||||
|
|
|
|||
|
|
@ -210,17 +210,26 @@ namespace GameCult.Networking
|
|||
/// </summary>
|
||||
public static class CultNetV0SelectionLowering
|
||||
{
|
||||
/// <summary>Lowers one v0 filter list; see the type summary.</summary>
|
||||
/// <summary>
|
||||
/// Lowers one v0 filter list; see the type summary. R-R (2026-09-22): a <c>null</c> list means
|
||||
/// "no filter" and stays <c>null</c>; a non-null list, even one that filters down to nothing,
|
||||
/// stays a real (possibly empty) array. Collapsing an explicit empty list to "no filter" was
|
||||
/// wrong - the pre-cut reference (<c>CultNetDatabase.CreateShardSnapshotResponse</c> at
|
||||
/// <c>b3d9cf7</c>) tests <c>filter?.RecordKeys != null</c>, not a length, so
|
||||
/// <c>recordKeys: []</c> always answered empty. The evaluator already honours that distinction
|
||||
/// (<c>CultNetSelectionEvaluator</c>: a non-null <c>Keys</c> array excludes every row when it
|
||||
/// is empty); only this lowering used to erase it before the selection ever reached the
|
||||
/// evaluator.
|
||||
/// </summary>
|
||||
public static string[]? Lower(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is not { Count: > 0 })
|
||||
if (values == null)
|
||||
return null;
|
||||
|
||||
var filtered = values
|
||||
return values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
return filtered.Length == 0 ? null : filtered;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2746,9 +2746,8 @@ public sealed class CultMeshStreamingTests
|
|||
|
||||
(await mixedAliasSchemaHandle.LatestAsync()).Text.Should().Be("compatible-record-key");
|
||||
|
||||
// MESH-KEY-Loosening (docs/cultnet-selection-cut.md, section 4/S2-4): the recordKey match is
|
||||
// exact (ordinal), not case-insensitive. A case-variant key must not win even when it appears
|
||||
// first in the response.
|
||||
// The recordKey match is exact (ordinal), not case-insensitive. A case-variant key must not
|
||||
// win even when it appears first in the response.
|
||||
var caseVariantKey = key.Value.ToUpperInvariant();
|
||||
var caseVariantKeyHandle = CultMesh.DocumentFromPeerSnapshot<MeshNoteAliasDocument>(
|
||||
_ => Task.FromResult(new CultNetSnapshotResponseRawMessage
|
||||
|
|
@ -2794,13 +2793,14 @@ public sealed class CultMeshStreamingTests
|
|||
(await caseVariantKeyHandle.LatestAsync()).Text.Should().Be("right-case-key");
|
||||
}
|
||||
|
||||
// MESH-DEFAULT-Loosening (docs/cultnet-selection-cut.md, section 4/S2-7): ResolveDefaultSelection's
|
||||
// no-schema-filter branch must trigger on a non-empty recordKeys, not merely a non-null one. An
|
||||
// explicit empty recordKeys array is a v0-compatible list that lowers to null (rule 1's v0
|
||||
// lowering), so it does not suppress the type's own schema default - the wire must carry
|
||||
// [T.SchemaId], not schemas=null.
|
||||
// R-R (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22) corrects the earlier commit-2
|
||||
// ruling this test used to pin: an explicit empty recordKeys array is a real filter, not "no
|
||||
// filter". ResolveDefaultSelection must not fall back to the type's own schema default just
|
||||
// because the caller's recordKeys happened to be empty - the wire must carry recordKeys=[] and
|
||||
// no schema default, and the answer is empty, exactly as v0 answered before this cut
|
||||
// (CultNetDatabase.CreateShardSnapshotResponse at b3d9cf7 tests RecordKeys != null, not a length).
|
||||
[Test]
|
||||
public async Task FetchDocumentsAsync_WithExplicitEmptyRecordKeys_StillDefaultsToOwnSchema()
|
||||
public async Task FetchDocumentsAsync_WithExplicitEmptyRecordKeys_AnswersEmpty()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var key = new CultRecordKey("mesh-note:empty-keys-default");
|
||||
|
|
@ -2828,11 +2828,11 @@ public sealed class CultMeshStreamingTests
|
|||
|
||||
var documents = await session.FetchDocumentsAsync<MeshNoteDocument>(recordKeys: Array.Empty<string>());
|
||||
|
||||
documents.Should().ContainSingle().Which.Text.Should().Be("empty-keys-default");
|
||||
documents.Should().BeEmpty();
|
||||
requests.Should().ContainSingle();
|
||||
requests[0].SchemaIds.Should().ContainSingle().Which
|
||||
.Should().Be(CultDocumentRegistry.Shared.GetRequired<MeshNoteDocument>().SchemaId);
|
||||
requests[0].RecordKeys.Should().BeNull();
|
||||
requests[0].SchemaIds.Should().BeNull();
|
||||
requests[0].RecordKeys.Should().NotBeNull();
|
||||
requests[0].RecordKeys.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
|
|
|||
|
|
@ -3284,6 +3284,63 @@ namespace GameCult.Networking.Tests
|
|||
Assert.That(SerializePlayerDataPayload(replicated!), Is.EqualTo(expectedPayload));
|
||||
}
|
||||
|
||||
// R-R (docs/cultnet-selection-cut.md, Self's rulings 2026-09-22): v0 lowering preserves v0's
|
||||
// pre-cut answers exactly. recordKeys: [] answers empty - it does not lower to "no filter".
|
||||
[Test]
|
||||
public async Task CultNetDocumentRegistry_RawSnapshot_ExplicitEmptyRecordKeys_AnswersEmpty()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var registry = new CultNetDocumentRegistry()
|
||||
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
|
||||
payloadSerializer: SerializePlayerDataPayload,
|
||||
payloadDeserializer: DeserializePlayerDataPayload));
|
||||
await cache.AddAsync(new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@test", PasswordHash = "x", Username = "A" });
|
||||
|
||||
var request = registry.CreateSnapshotRequest("request-empty", recordKeys: Array.Empty<string>());
|
||||
var response = registry.CreateRawSnapshotResponse(cache, "snapshot-empty", request);
|
||||
|
||||
Assert.That(response.Documents, Is.Empty);
|
||||
}
|
||||
|
||||
// R-R: v0's pre-cut CreateRawSnapshotResponse iterated the requested record keys in the order
|
||||
// the caller sent them, not in schema-and-key sort order. This pins that order survives the
|
||||
// move to the shared evaluator.
|
||||
[Test]
|
||||
public async Task CultNetDocumentRegistry_RawSnapshot_KeepsRequestedRecordKeyOrder()
|
||||
{
|
||||
var cache = new CultCache();
|
||||
var registry = new CultNetDocumentRegistry()
|
||||
.Register(CultNetDocumentBinding.ForDocument<PlayerData>(
|
||||
payloadSerializer: SerializePlayerDataPayload,
|
||||
payloadDeserializer: DeserializePlayerDataPayload));
|
||||
|
||||
var players = new[] { "zeta", "alpha", "mid" }
|
||||
.Select(name => new PlayerData { PlayerId = Guid.NewGuid(), Email = $"{name}@test", PasswordHash = "x", Username = name })
|
||||
.ToArray();
|
||||
var handles = new List<CultRecordHandle<PlayerData>>();
|
||||
foreach (var player in players)
|
||||
handles.Add(await cache.AddAsync(player));
|
||||
|
||||
// Request in an order that matches neither the insertion order nor the ordinal-sorted
|
||||
// record-key order, so a schema-and-key sort would visibly reorder the response.
|
||||
var requestedOrder = new[] { handles[2].Key.Value, handles[0].Key.Value, handles[1].Key.Value };
|
||||
var request = registry.CreateSnapshotRequest("request-order", recordKeys: requestedOrder);
|
||||
var response = registry.CreateRawSnapshotResponse(cache, "snapshot-order", request);
|
||||
|
||||
Assert.That(response.Documents.Select(d => d.RecordKey), Is.EqualTo(requestedOrder));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CultNetV0SelectionLowering_Lower_DistinguishesNullFromExplicitEmpty()
|
||||
{
|
||||
Assert.That(CultNetV0SelectionLowering.Lower(null), Is.Null);
|
||||
Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty<string>()), Is.Not.Null);
|
||||
Assert.That(CultNetV0SelectionLowering.Lower(Array.Empty<string>()), Is.Empty);
|
||||
Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Not.Null);
|
||||
Assert.That(CultNetV0SelectionLowering.Lower(new[] { "", " " }), Is.Empty);
|
||||
Assert.That(CultNetV0SelectionLowering.Lower(new[] { "b", "a", "b" }), Is.EqualTo(new[] { "b", "a" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CultNetDatabase_PutGet_AndWatchByIndex_Uses_PrimaryShard()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue