CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
#nullable enable
|
|
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using GameCult.Caching;
|
|
|
|
|
using MessagePack;
|
|
|
|
|
using NUnit.Framework;
|
|
|
|
|
|
|
|
|
|
namespace GameCult.Networking.Tests
|
|
|
|
|
{
|
|
|
|
|
// CultNet typed selection, Cut 1, commit 1 (docs/cultnet-selection-cut.md): the shape, its
|
|
|
|
|
// validation door and the one evaluator. Independent of any live server/client transport - these
|
|
|
|
|
// exercise CultNetSelection/CultNetSelectionEvaluator directly against a registry and a hand-built
|
|
|
|
|
// row set, matching section 10's S1-S23 naming.
|
|
|
|
|
public sealed class CultNetSelectionEvaluatorTests
|
|
|
|
|
{
|
|
|
|
|
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[]
|
|
|
|
|
{
|
|
|
|
|
typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiter)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) =>
|
|
|
|
|
new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal);
|
|
|
|
|
|
|
|
|
|
// S1: an index no reachable schema declares, an empty values list, and an empty schemas/keys
|
|
|
|
|
// list are refused typed at the door, never answered as an empty page.
|
|
|
|
|
[Test]
|
|
|
|
|
public void ValidationRefusesUndeclaredIndexRoleAndEmptyLists()
|
|
|
|
|
{
|
|
|
|
|
var descriptors = Registry().AllDescriptors.ToArray();
|
|
|
|
|
|
|
|
|
|
AssertInvalid(new CultNetSelection { Schemas = Array.Empty<string>() }, descriptors, "schemas");
|
|
|
|
|
AssertInvalid(new CultNetSelection { Keys = Array.Empty<string>() }, descriptors, "keys");
|
|
|
|
|
AssertInvalid(
|
|
|
|
|
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "not_declared", Op = "any_of", Values = new[] { "x" } } } },
|
|
|
|
|
descriptors,
|
|
|
|
|
"fields[0].index");
|
|
|
|
|
AssertInvalid(
|
|
|
|
|
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "any_of", Values = Array.Empty<string>() } } },
|
|
|
|
|
descriptors,
|
|
|
|
|
"fields[0].values");
|
|
|
|
|
AssertInvalid(
|
|
|
|
|
new CultNetSelection { Cited = new CultNetIncoming { Role = "no_such_role", Exists = true } },
|
|
|
|
|
descriptors,
|
|
|
|
|
"cited.role");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void AssertInvalid(CultNetSelection selection, IReadOnlyList<CultDocumentDescriptor> descriptors, string expectedField)
|
|
|
|
|
{
|
|
|
|
|
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
|
|
|
|
|
Assert.That(ex!.Field, Is.EqualTo(expectedField));
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-09-22 04:18:22 +02:00
|
|
|
// 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");
|
|
|
|
|
}
|
|
|
|
|
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
// S2: fields conjoin; any_of matches on membership.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorConjoinsAnyOfPredicatesOverDeclaredIndexes()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var a = new SelLeafA { Name = "sword", Kind = "weapon", Mass = 3 };
|
|
|
|
|
var b = new SelLeafA { Name = "shield", Kind = "armor", Mass = 3 };
|
|
|
|
|
var rows = new[] { Row(registry, a, "a", 1), Row(registry, b, "b", 2) };
|
|
|
|
|
var selection = new CultNetSelection
|
|
|
|
|
{
|
|
|
|
|
Fields = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } },
|
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>
2026-09-22 04:18:22 +02:00
|
|
|
new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "3" }
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
|
|
|
|
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// S3: order is (ordinal, schemaId, recordKey) ascending, reversed under descending.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorOrdersByOrdinalThenIdentityAndReverses()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var rows = new[]
|
|
|
|
|
{
|
|
|
|
|
Row(registry, new SelLeafA { Name = "c", Kind = "k", Mass = 1 }, "c", 3),
|
|
|
|
|
Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1),
|
|
|
|
|
Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var ascending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection(), asOf: 1);
|
|
|
|
|
Assert.That(ascending.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a", "b", "c" }));
|
|
|
|
|
|
|
|
|
|
var descending = CultNetSelectionEvaluator.Select(registry, rows, new CultNetSelection { Descending = true }, asOf: 1);
|
|
|
|
|
Assert.That(descending.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "c", "b", "a" }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// S4: a page walk visits every row exactly once and the last page carries no cursor.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorPagesExactlyOnceAndTheLastPageSaysSo()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var rows = Enumerable.Range(0, 5)
|
|
|
|
|
.Select(i => Row(registry, new SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i))
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
|
var selection = new CultNetSelection { Limit = 2 };
|
|
|
|
|
var seen = new List<string>();
|
|
|
|
|
string? cursor = null;
|
|
|
|
|
for (var guard = 0; guard < 10; guard++)
|
|
|
|
|
{
|
|
|
|
|
selection.Cursor = cursor;
|
|
|
|
|
var page = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
|
|
|
seen.AddRange(page.Rows.Select(r => r.Key.Value));
|
|
|
|
|
if (page.NextCursor == null) break;
|
|
|
|
|
cursor = page.NextCursor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Assert.That(seen, Is.EqualTo(new[] { "k0", "k1", "k2", "k3", "k4" }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// S5: a cursor answers only the asOf and the selection it was minted for.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorRefusesAStaleCursorAndAMismatchedSelection()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var rows = new[]
|
|
|
|
|
{
|
|
|
|
|
Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1),
|
|
|
|
|
Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2)
|
|
|
|
|
};
|
|
|
|
|
var selection = new CultNetSelection { Limit = 1 };
|
|
|
|
|
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
|
|
|
Assert.That(first.NextCursor, Is.Not.Null);
|
|
|
|
|
|
|
|
|
|
selection.Cursor = first.NextCursor;
|
|
|
|
|
Assert.That(
|
|
|
|
|
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 2),
|
|
|
|
|
Throws.TypeOf<CultNetSelectionCursorException>()
|
|
|
|
|
.With.Property(nameof(CultNetSelectionCursorException.Code)).EqualTo("cursor_stale"));
|
|
|
|
|
|
|
|
|
|
var otherSelection = new CultNetSelection { Limit = 1, Cursor = first.NextCursor, Descending = true };
|
|
|
|
|
Assert.That(
|
|
|
|
|
() => CultNetSelectionEvaluator.Select(registry, rows, otherSelection, asOf: 1),
|
|
|
|
|
Throws.TypeOf<CultNetSelectionCursorException>()
|
|
|
|
|
.With.Property(nameof(CultNetSelectionCursorException.Code)).EqualTo("cursor_invalid"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 19:44:19 +02:00
|
|
|
// R-N (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): every refusal maps to a
|
|
|
|
|
// wire code, and selection_invalid/cursor_stale carry structured details, on cultnet.error.v0.
|
|
|
|
|
// Each test here decodes the bytes a peer actually receives (CultNetSchemaMessageSerialization
|
|
|
|
|
// round trip), not the in-process exception or message object.
|
|
|
|
|
[Test]
|
|
|
|
|
public void ErrorMessage_WireDecodesSelectionInvalidCodeAndDetails()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var ex = Assert.Throws<CultNetSelectionInvalidException>(
|
|
|
|
|
() => new CultNetSelection { Schemas = Array.Empty<string>() }.Validate(registry.AllDescriptors.ToArray()))!;
|
|
|
|
|
|
|
|
|
|
var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForSelectionInvalid(ex));
|
|
|
|
|
var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
|
|
|
|
|
|
|
|
|
|
Assert.That(decoded.Code, Is.EqualTo("selection_invalid"));
|
|
|
|
|
Assert.That(decoded.Details, Is.Not.Null);
|
|
|
|
|
Assert.That(decoded.Details!.Field, Is.EqualTo("schemas"));
|
|
|
|
|
Assert.That(decoded.Details!.Value, Is.Null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ErrorMessage_WireDecodesCursorStaleCodeAndDetails()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var rows = new[]
|
|
|
|
|
{
|
|
|
|
|
Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1),
|
|
|
|
|
Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2)
|
|
|
|
|
};
|
|
|
|
|
var selection = new CultNetSelection { Limit = 1 };
|
|
|
|
|
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
|
|
|
selection.Cursor = first.NextCursor;
|
|
|
|
|
var ex = Assert.Throws<CultNetSelectionCursorException>(
|
|
|
|
|
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 2))!;
|
|
|
|
|
|
|
|
|
|
var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForCursor(ex));
|
|
|
|
|
var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
|
|
|
|
|
|
|
|
|
|
Assert.That(decoded.Code, Is.EqualTo("cursor_stale"));
|
|
|
|
|
Assert.That(decoded.Details, Is.Not.Null);
|
|
|
|
|
Assert.That(decoded.Details!.AsOf, Is.EqualTo(1ul));
|
|
|
|
|
Assert.That(decoded.Details!.Current, Is.EqualTo(2ul));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ErrorMessage_WireDecodesCursorInvalidCodeWithNoDetails()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var rows = new[]
|
|
|
|
|
{
|
|
|
|
|
Row(registry, new SelLeafA { Name = "a", Kind = "k", Mass = 1 }, "a", 1),
|
|
|
|
|
Row(registry, new SelLeafA { Name = "b", Kind = "k", Mass = 1 }, "b", 2)
|
|
|
|
|
};
|
|
|
|
|
var selection = new CultNetSelection { Limit = 1 };
|
|
|
|
|
var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
|
|
|
var mismatched = new CultNetSelection { Limit = 1, Cursor = first.NextCursor, Descending = true };
|
|
|
|
|
var ex = Assert.Throws<CultNetSelectionCursorException>(
|
|
|
|
|
() => CultNetSelectionEvaluator.Select(registry, rows, mismatched, asOf: 1))!;
|
|
|
|
|
|
|
|
|
|
var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForCursor(ex));
|
|
|
|
|
var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
|
|
|
|
|
|
|
|
|
|
Assert.That(decoded.Code, Is.EqualTo("cursor_invalid"));
|
|
|
|
|
Assert.That(decoded.Details, Is.Null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ErrorMessage_WireDecodesReferenceOutsideTargetCodeWithNoDetails()
|
|
|
|
|
{
|
|
|
|
|
var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 };
|
|
|
|
|
var offTargetCiter = new SelCiterNarrow { Name = "bad", NarrowRef = new CultRecordRef<SelLeafA>(new CultRecordKey("b")) };
|
|
|
|
|
var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterNarrow) });
|
|
|
|
|
var rows = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
|
|
|
|
|
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelCiterNarrow>(), new CultRecordKey("bad"), offTargetCiter, 2)
|
|
|
|
|
};
|
|
|
|
|
var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true } };
|
|
|
|
|
var ex = Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
|
|
|
|
|
() => CultNetSelectionEvaluator.Select(narrowRegistry, rows, selection, asOf: 1))!;
|
|
|
|
|
|
|
|
|
|
var payload = CultNetSchemaMessageSerialization.Serialize(CultNetErrorMessage.ForReferenceOutsideTarget(ex));
|
|
|
|
|
var decoded = (CultNetErrorMessage)CultNetSchemaMessageSerialization.Deserialize(payload);
|
|
|
|
|
|
|
|
|
|
Assert.That(decoded.Code, Is.EqualTo("reference_outside_target"));
|
|
|
|
|
Assert.That(decoded.Details, Is.Null);
|
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap
R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte
random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key
instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not
verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body
(asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no
delimiter) instead of joined with a bare "\x01"-separated string, so a record key
carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/
Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a
process-wide key generated once so existing callers that never wire up a database still
get a keyed digest. CultNetDatabase gets a CursorKey property (random unless
CultNetDatabaseOptions.CursorKey is supplied) and passes it through
CultNetDatabaseServer.CreateSelectionResponse.
Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching
the reference: its own doc comment says a cursor is never handed across runtimes, so only
collision-freedom and keying are load-bearing, not the wire bytes. That is a real
divergence from this pass's instruction to match Rust's wire encoding exactly - reported
in full in the batch report.
R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded
the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position
looked like "never written" until it was written again locally. Both now set it from the
entry's own sequence, matching AppendMutationLogEntry's local-commit path.
InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also
rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence
order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard
resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional
shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid,
R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is
a single watermark and is not exact for a shard-spanning page.
CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the
database.
Also fixes a defect the Rust Hands found while matching wire bytes:
CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: "
- the exception's own message already carries it. Now prefixed once, matching Rust's
ForReferenceOutsideTarget.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
|
|
|
// ForReferenceOutsideTarget used to prefix "reference_outside_target: " a second time on
|
|
|
|
|
// top of the exception's own message, which already carries it - a peer received the code
|
|
|
|
|
// name twice. One prefix only, on the wire bytes a peer actually decodes.
|
|
|
|
|
Assert.That(decoded.Error, Does.StartWith("reference_outside_target: "));
|
|
|
|
|
Assert.That(decoded.Error, Does.Not.Contain("reference_outside_target: reference_outside_target:"));
|
2026-09-22 19:44:19 +02:00
|
|
|
}
|
|
|
|
|
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
// S6: the hop follows one declared reference by role; a second role is not followed.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorHopsOneEdgeByDeclaredReferenceAndRole()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var target = new SelLeafA { Name = "target", Kind = "k", Mass = 1 };
|
|
|
|
|
var citer = new SelCiter { Name = "citer", Design = new CultRecordRef<SelFixtureMiddle>(new CultRecordKey("target")) };
|
|
|
|
|
var rows = new[] { Row(registry, target, "target", 1), Row(registry, citer, "citer", 2) };
|
|
|
|
|
|
|
|
|
|
var selection = new CultNetSelection
|
|
|
|
|
{
|
|
|
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = registry.GetRequired<SelLeafA>().SchemaId, RecordKey = "target" }, Role = "Design" }
|
|
|
|
|
};
|
|
|
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "citer" }));
|
|
|
|
|
Assert.That(evaluation.Edges.Single().Role, Is.EqualTo("Design"));
|
|
|
|
|
|
|
|
|
|
var wrongRole = new CultNetSelection
|
|
|
|
|
{
|
|
|
|
|
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = registry.GetRequired<SelLeafA>().SchemaId, RecordKey = "target" }, Role = "NotARole" }
|
|
|
|
|
};
|
|
|
|
|
// Validate refuses an undeclared role before evaluation ever runs (S1).
|
|
|
|
|
Assert.Throws<CultNetSelectionInvalidException>(() => wrongRole.Validate(registry.AllDescriptors.ToArray()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// S7: cited { exists: false } is the one negation.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorNegatedIncomingEdgeIsTheOnlyNegation()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var cited = new SelLeafA { Name = "cited", Kind = "k", Mass = 1 };
|
|
|
|
|
var uncited = new SelLeafA { Name = "uncited", Kind = "k", Mass = 1 };
|
|
|
|
|
var citer = new SelCiter { Name = "citer", Design = new CultRecordRef<SelFixtureMiddle>(new CultRecordKey("cited")) };
|
|
|
|
|
var rows = new[] { Row(registry, cited, "cited", 1), Row(registry, uncited, "uncited", 2), Row(registry, citer, "citer", 3) };
|
|
|
|
|
|
|
|
|
|
var exists = CultNetSelectionEvaluator.Select(registry, rows,
|
|
|
|
|
new CultNetSelection { Schemas = new[] { registry.GetRequired<SelLeafA>().SchemaId }, Cited = new CultNetIncoming { Role = "Design", Exists = true } },
|
|
|
|
|
asOf: 1);
|
|
|
|
|
Assert.That(exists.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "cited" }));
|
|
|
|
|
|
|
|
|
|
var absent = CultNetSelectionEvaluator.Select(registry, rows,
|
|
|
|
|
new CultNetSelection { Schemas = new[] { registry.GetRequired<SelLeafA>().SchemaId }, Cited = new CultNetIncoming { Role = "Design", Exists = false } },
|
|
|
|
|
asOf: 1);
|
|
|
|
|
Assert.That(absent.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "uncited" }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// S8: header projection carries no payload; edges under header carry no payload either (S20).
|
|
|
|
|
[Test]
|
|
|
|
|
public void HeaderProjectionCarriesNoPayload()
|
|
|
|
|
{
|
|
|
|
|
var record = new CultNetRawDocumentRecord
|
|
|
|
|
{
|
|
|
|
|
SchemaId = "s", RecordKey = "k", StoredAt = "now", PayloadEncoding = "messagepack", Payload = new byte[] { 1, 2, 3 }
|
|
|
|
|
};
|
|
|
|
|
var header = CultNetRawDocumentHeader.FromRecord(record);
|
|
|
|
|
Assert.That(header.SchemaId, Is.EqualTo("s"));
|
|
|
|
|
// CultNetRawDocumentHeader has no Payload/PayloadEncoding members at all - a compile-time
|
|
|
|
|
// guarantee, not merely a null check; this test exists to name the rule.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// S16: the four comparisons at the boundary, including equal-to-the-compared-number rows.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorComparesNumbersAtTheBoundaryForEachOfTheFourOperators()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var rows = new[]
|
|
|
|
|
{
|
|
|
|
|
Row(registry, new SelLeafA { Name = "lo", Kind = "k", Mass = 4 }, "lo", 1),
|
|
|
|
|
Row(registry, new SelLeafA { Name = "eq", Kind = "k", Mass = 5 }, "eq", 2),
|
|
|
|
|
Row(registry, new SelLeafA { Name = "hi", Kind = "k", Mass = 6 }, "hi", 3)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
string[] Matches(string op) => CultNetSelectionEvaluator.Select(
|
|
|
|
|
registry, rows,
|
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>
2026-09-22 04:18:22 +02:00
|
|
|
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = op, Number = "5" } } },
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
asOf: 1).Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal).ToArray();
|
|
|
|
|
|
|
|
|
|
Assert.That(Matches("lt"), Is.EqualTo(new[] { "lo" }));
|
|
|
|
|
Assert.That(Matches("le"), Is.EqualTo(new[] { "eq", "lo" }));
|
|
|
|
|
Assert.That(Matches("ge"), Is.EqualTo(new[] { "eq", "hi" }));
|
|
|
|
|
Assert.That(Matches("gt"), Is.EqualTo(new[] { "hi" }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// S17: an alias declared once on the abstract middle reaches every leaf that inherits it.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorAppliesAnInheritedAliasToEveryReachableLeaf()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var a = new SelLeafA { Name = "a", Kind = "k", Mass = 20 };
|
|
|
|
|
var b = new SelLeafB { Name = "b", Kind = "k", Mass = 20 };
|
|
|
|
|
var rows = new[] { Row(registry, a, "a", 1), Row(registry, b, "b", 2) };
|
|
|
|
|
|
|
|
|
|
var evaluation = CultNetSelectionEvaluator.Select(
|
|
|
|
|
registry, rows,
|
|
|
|
|
new CultNetSelection
|
|
|
|
|
{
|
|
|
|
|
Schemas = new[] { registry.GetRequired<SelLeafA>().SchemaId, registry.GetRequired<SelLeafB>().SchemaId },
|
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>
2026-09-22 04:18:22 +02:00
|
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = "10" } }
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
},
|
|
|
|
|
asOf: 1);
|
|
|
|
|
|
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal), Is.EqualTo(new[] { "a", "b" }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// S18: an edge naming a row outside the reference's declared target refuses the selection.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorRefusesAnEdgeOutsideItsDeclaredTarget()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 };
|
|
|
|
|
// Design's declared target is SelFixtureMiddle (an abstract type with no schema of its own),
|
|
|
|
|
// and both leaves are within that target, so this citer is a legitimate in-target edge.
|
|
|
|
|
var okCiter = new SelCiter { Name = "ok", Design = new CultRecordRef<SelFixtureMiddle>(new CultRecordKey("b")) };
|
|
|
|
|
var refA = registry.GetRequired<SelLeafA>();
|
|
|
|
|
var offTargetCiter = new SelCiterNarrow { Name = "bad", NarrowRef = new CultRecordRef<SelLeafA>(new CultRecordKey("b")) };
|
|
|
|
|
var narrowRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterNarrow) });
|
|
|
|
|
var rows = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
|
|
|
|
|
new CultNetSelectionEvaluator.Row(narrowRegistry.GetRequired<SelCiterNarrow>(), new CultRecordKey("bad"), offTargetCiter, 2)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var selection = new CultNetSelection
|
|
|
|
|
{
|
|
|
|
|
Cited = new CultNetIncoming { Role = "NarrowRef", Exists = true }
|
|
|
|
|
};
|
|
|
|
|
Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
|
|
|
|
|
() => CultNetSelectionEvaluator.Select(narrowRegistry, rows, selection, asOf: 1));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-22 19:44:19 +02:00
|
|
|
// R-T(b) (docs/cultnet-selection-cut.md, Self's rulings for fix batch 3): EnsureWithinDeclaredTarget
|
|
|
|
|
// checks every edge against its declared target, including a many reference declared with no
|
|
|
|
|
// explicit [CultReference(typeof(...))] - CultDocumentRegistry.PersistedMember used to leave
|
|
|
|
|
// TargetType null for that shape (only the explicit-target and scalar CultRecordRef<T> cases were
|
|
|
|
|
// inferred), which made EnsureWithinDeclaredTarget skip the check outright for every edge through
|
|
|
|
|
// it. SelCiterManyUntyped's ManyRefs declares many:true with no explicit target, so its target is
|
|
|
|
|
// inferred from the CultRecordRef<SelLeafA> element alone; a SelLeafB target must still refuse.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorRefusesAnOutOfTargetEdgeThroughAnImplicitlyTypedManyReference()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SelLeafA), typeof(SelLeafB), typeof(SelCiterManyUntyped) });
|
|
|
|
|
var leafA = new SelLeafA { Name = "a", Kind = "k", Mass = 1 };
|
|
|
|
|
var leafB = new SelLeafB { Name = "b", Kind = "k", Mass = 1 };
|
|
|
|
|
var okCiter = new SelCiterManyUntyped
|
|
|
|
|
{
|
|
|
|
|
Name = "ok",
|
|
|
|
|
ManyRefs = new[] { new CultRecordRef<SelLeafA>(new CultRecordKey("a")) }
|
|
|
|
|
};
|
|
|
|
|
var offTargetCiter = new SelCiterManyUntyped
|
|
|
|
|
{
|
|
|
|
|
Name = "bad",
|
|
|
|
|
ManyRefs = new[] { new CultRecordRef<SelLeafA>(new CultRecordKey("b")) }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var okRows = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelLeafA>(), new CultRecordKey("a"), leafA, 1),
|
|
|
|
|
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelCiterManyUntyped>(), new CultRecordKey("ok"), okCiter, 2)
|
|
|
|
|
};
|
|
|
|
|
var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "ManyRefs", Exists = true } };
|
|
|
|
|
var okEvaluation = CultNetSelectionEvaluator.Select(registry, okRows, selection, asOf: 1);
|
|
|
|
|
Assert.That(okEvaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" }));
|
|
|
|
|
|
|
|
|
|
var badRows = new[]
|
|
|
|
|
{
|
|
|
|
|
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelLeafB>(), new CultRecordKey("b"), leafB, 1),
|
|
|
|
|
new CultNetSelectionEvaluator.Row(registry.GetRequired<SelCiterManyUntyped>(), new CultRecordKey("bad"), offTargetCiter, 2)
|
|
|
|
|
};
|
|
|
|
|
Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
|
|
|
|
|
() => CultNetSelectionEvaluator.Select(registry, badRows, selection, asOf: 1));
|
|
|
|
|
}
|
|
|
|
|
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
// S23: the evaluator matches every row sharing an index value, never the cache's own
|
|
|
|
|
// last-writer-wins unique-index map. Three rows share "shared" here.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorMatchesEveryRowSharingAnIndexValueNotAWinner()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var rows = new[]
|
|
|
|
|
{
|
|
|
|
|
Row(registry, new SelLeafA { Name = "1", Kind = "shared", Mass = 1 }, "1", 1),
|
|
|
|
|
Row(registry, new SelLeafA { Name = "2", Kind = "shared", Mass = 1 }, "2", 2),
|
|
|
|
|
Row(registry, new SelLeafA { Name = "3", Kind = "shared", Mass = 1 }, "3", 3)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var evaluation = CultNetSelectionEvaluator.Select(
|
|
|
|
|
registry, rows,
|
|
|
|
|
new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "shared" } } } },
|
|
|
|
|
asOf: 1);
|
|
|
|
|
|
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal), Is.EqualTo(new[] { "1", "2", "3" }));
|
|
|
|
|
}
|
|
|
|
|
|
Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap
R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte
random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key
instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not
verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body
(asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (<byte length>:<value>, no
delimiter) instead of joined with a bare "\x01"-separated string, so a record key
carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/
Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a
process-wide key generated once so existing callers that never wire up a database still
get a keyed digest. CultNetDatabase gets a CursorKey property (random unless
CultNetDatabaseOptions.CursorKey is supplied) and passes it through
CultNetDatabaseServer.CreateSelectionResponse.
Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching
the reference: its own doc comment says a cursor is never handed across runtimes, so only
collision-freedom and keying are load-bearing, not the wire bytes. That is a real
divergence from this pass's instruction to match Rust's wire encoding exactly - reported
in full in the batch report.
R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded
the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position
looked like "never written" until it was written again locally. Both now set it from the
entry's own sequence, matching AppendMutationLogEntry's local-commit path.
InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also
rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence
order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard
resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional
shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid,
R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is
a single watermark and is not exact for a shard-spanning page.
CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the
database.
Also fixes a defect the Rust Hands found while matching wire bytes:
CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: "
- the exception's own message already carries it. Now prefixed once, matching Rust's
ForReferenceOutsideTarget.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:04:22 +02:00
|
|
|
// S1-Loose (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 3" -> "Surviving
|
|
|
|
|
// mutants"): ReachableSchemas narrows to the schemas selection.schemas actually names; a mutant
|
|
|
|
|
// that widens it back to every registered descriptor lets a predicate on an index only an
|
|
|
|
|
// EXCLUDED schema declares reach the door silently (matching nothing, never refusing), instead
|
|
|
|
|
// of the typed "not declared by any schema this selection can reach" refusal. "kind" is declared
|
|
|
|
|
// on SelLeafA/SelLeafB, not on SelCiter; Schemas here names only SelCiter.
|
|
|
|
|
[Test]
|
|
|
|
|
public void ValidationRefusesAnIndexDeclaredOnlyOnASchemaTheSelectionDoesNotReach()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var descriptors = registry.AllDescriptors.ToArray();
|
|
|
|
|
var selection = new CultNetSelection
|
|
|
|
|
{
|
|
|
|
|
Schemas = new[] { registry.GetRequired<SelCiter>().SchemaId },
|
|
|
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "x" } } }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var ex = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(descriptors));
|
|
|
|
|
Assert.That(ex!.Field, Is.EqualTo("fields[0].index"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// S2-AllOf (fix batch 3 "Surviving mutants"): any_of matches when the row's declared value
|
|
|
|
|
// equals ANY one of the predicate's values, not when it equals every one of them - impossible
|
|
|
|
|
// for a single-valued index whenever Values carries more than one entry, so a mutant that
|
|
|
|
|
// requires all of them makes a real multi-value any_of predicate match nothing.
|
|
|
|
|
[Test]
|
|
|
|
|
public void EvaluatorAnyOfMatchesWhenTheRowsValueIsAnyOneOfSeveralPredicateValues()
|
|
|
|
|
{
|
|
|
|
|
var registry = Registry();
|
|
|
|
|
var row = Row(registry, new SelLeafA { Name = "a", Kind = "weapon", Mass = 1 }, "a", 1);
|
|
|
|
|
var selection = new CultNetSelection
|
|
|
|
|
{
|
|
|
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "armor", "weapon" } } }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, new[] { row }, selection, asOf: 1);
|
|
|
|
|
|
|
|
|
|
Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" }));
|
|
|
|
|
}
|
|
|
|
|
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
public abstract class SelFixtureMiddle
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
[CultIndex("mass")]
|
|
|
|
|
public float Mass;
|
|
|
|
|
|
|
|
|
|
[Key(2)]
|
|
|
|
|
[CultIndex("kind")]
|
|
|
|
|
public string Kind = string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[CultDocument("cultnet.selection-tests.leaf_a", "cultnet.selection-tests.leaf_a.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
public sealed class SelLeafA : SelFixtureMiddle
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[CultDocument("cultnet.selection-tests.leaf_b", "cultnet.selection-tests.leaf_b.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
public sealed class SelLeafB : SelFixtureMiddle
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[CultDocument("cultnet.selection-tests.citer", "cultnet.selection-tests.citer.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
public sealed class SelCiter
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
[CultReference(typeof(SelFixtureMiddle))]
|
|
|
|
|
public CultRecordRef<SelFixtureMiddle> Design;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[CultDocument("cultnet.selection-tests.citer_narrow", "cultnet.selection-tests.citer_narrow.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
public sealed class SelCiterNarrow
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
|
|
|
|
|
// Declared target is the single leaf SelLeafA, not the abstract middle - a value pointing at
|
|
|
|
|
// a SelLeafB row is outside this reference's declared target set (S18).
|
|
|
|
|
[Key(1)]
|
|
|
|
|
[CultReference(typeof(SelLeafA))]
|
|
|
|
|
public CultRecordRef<SelLeafA> NarrowRef;
|
|
|
|
|
}
|
2026-09-22 19:44:19 +02:00
|
|
|
|
|
|
|
|
[CultDocument("cultnet.selection-tests.citer_many_untyped", "cultnet.selection-tests.citer_many_untyped.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
public sealed class SelCiterManyUntyped
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
|
|
|
|
|
// No explicit [CultReference(typeof(...))] target - the target is inferred from the
|
|
|
|
|
// CultRecordRef<SelLeafA> element alone (R-T(b)).
|
|
|
|
|
[Key(1)]
|
|
|
|
|
[CultReference(many: true)]
|
|
|
|
|
public CultRecordRef<SelLeafA>[] ManyRefs = Array.Empty<CultRecordRef<SelLeafA>>();
|
|
|
|
|
}
|
CultNet selection cut1, commit 1: the shape, the evaluator, v1 messages
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:31:42 +02:00
|
|
|
}
|
|
|
|
|
}
|