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:
Meta Vens 2026-09-22 19:13:31 +02:00
commit 5e33d81e25
5 changed files with 108 additions and 22 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
{