CultNet selection cut1, commit 0: the cache's public read surface
docs/cultnet-selection-cut.md section 6/7/10: the reflection loop at
CultNetDocumentRegistry.cs:325-342 exists only because nothing outside
GameCult.Caching can read a declared index value, reference, or numeric
member (Members/IndexAccessors are internal, no InternalsVisibleTo to
Networking). This adds the one public door instead: CultDocumentMemberView,
CultDocumentDescriptor.DeclaredMembers/TryGetIndexValue/TryGetIndexNumber/
ReferencesOf, and CultDocumentRegistry.ResolveTargetLeaves (D9, computed live
against the registry, never cached or persisted).
Two registration refusals land beside DiscoverMembers' existing
DuplicateSlotMessage/HiddenMemberMessage: D10 renames the ArgumentException
ToDictionary already threw for two differently-named members sharing one
index alias into a rejection that names the schema, both members and the
alias; D11 refuses a reference member whose CLR shape the cache cannot walk
(many:false needs CultRecordRef<T>, many:true needs
IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>) instead of
silently answering it as unreadable later.
GameCult.Networking is untouched; the evaluator that will read this surface
does not exist yet (commit 1).
Verification: dotnet test tests/GameCult.Caching.Tests - 188 passed (176
existing + 12 new), 0 failed. New tests: TryGetIndexValue/TryGetIndexNumber
reading an inherited alias; ReferencesOf over a single/many-list/many-dictionary
reference, including two dictionary entries with different payload bytes
(S19); ResolveTargetLeaves against an abstract target with no [CultDocument]
of its own (D9), including a leaf registered after an earlier descriptor was
built; IsNumeric agreement for a nullable numeric and a decimal member (S22);
the two new registration refusals, dynamically emitted so registry discovery
by attribute scan cannot see the rejected shapes.
scripts/mutate-cultnet-selection-caching.ps1: a dedicated dotnet/NUnit
mutation runner (the shared tools/eureka-mutations.ps1 harness only speaks
cargo; scripts/mutate-cultmesh.mjs is the QUIC campaign's file and this
campaign does not edit it, per the map's section 10). Byte-exact restore
verified by SHA-256, a sidecar for crash recovery, M0 no-op control, one
anchor match required per entry. Six entries (D8/S22, D10, D11 - revert and
loosening each) all KILLED; final restore verified green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 01:57:35 +02:00
|
|
|
#nullable enable
|
|
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using System.Reflection;
|
|
|
|
|
using System.Reflection.Emit;
|
|
|
|
|
using GameCult.Caching;
|
|
|
|
|
using MessagePack;
|
|
|
|
|
using NUnit.Framework;
|
|
|
|
|
|
|
|
|
|
namespace GameCult.Caching.Tests
|
|
|
|
|
{
|
|
|
|
|
// CultNet typed selection, Cut 1, commit 0: the cache's public read surface over declared members
|
|
|
|
|
// (docs/cultnet-selection-cut.md, section 6). These tests are independent of GameCult.Networking -
|
|
|
|
|
// the evaluator that will consume this surface does not exist yet in this commit.
|
|
|
|
|
public sealed class CultDocumentSelectionSurfaceTests
|
|
|
|
|
{
|
|
|
|
|
// S19: a dictionary reference is enumerated as edges carrying its values, not merely its keys.
|
|
|
|
|
[Test]
|
|
|
|
|
public void ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
|
|
|
|
|
var citer = registry.GetRequired<FixtureCiter>();
|
|
|
|
|
var a1 = new CultRecordRef<FixtureLeafA>(new CultRecordKey("leaf-a-1"));
|
|
|
|
|
var a2 = new CultRecordRef<FixtureLeafA>(new CultRecordKey("leaf-a-2"));
|
|
|
|
|
var document = new FixtureCiter
|
|
|
|
|
{
|
|
|
|
|
Components = new Dictionary<CultRecordRef<FixtureLeafA>, float> { [a1] = 1.5f, [a2] = 2.5f }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var edges = citer.ReferencesOf(document, "Components").ToArray();
|
|
|
|
|
|
|
|
|
|
Assert.That(edges.Select(edge => edge.Target.Value).OrderBy(v => v, StringComparer.Ordinal),
|
|
|
|
|
Is.EqualTo(new[] { "leaf-a-1", "leaf-a-2" }));
|
|
|
|
|
Assert.That(edges.Single(edge => edge.Target.Value == "leaf-a-1").Payload, Is.EqualTo(1.5f));
|
|
|
|
|
Assert.That(edges.Single(edge => edge.Target.Value == "leaf-a-2").Payload, Is.EqualTo(2.5f));
|
|
|
|
|
// Two entries with different payload bytes: a shape-only assertion (keys enumerated, values
|
|
|
|
|
// dropped) would pass without this distinguishing the payloads (spec section 10, S19).
|
|
|
|
|
Assert.That(edges.Select(edge => edge.Payload).Distinct().Count(), Is.EqualTo(2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ReferencesOfEnumeratesAManyListReferenceWithNoPayload()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
|
|
|
|
|
var citer = registry.GetRequired<FixtureCiter>();
|
|
|
|
|
var document = new FixtureCiter
|
|
|
|
|
{
|
|
|
|
|
Related = new List<CultRecordRef<FixtureLeafA>>
|
|
|
|
|
{
|
|
|
|
|
new(new CultRecordKey("leaf-a-3")),
|
|
|
|
|
new(new CultRecordKey("leaf-a-4"))
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var edges = citer.ReferencesOf(document, "Related").ToArray();
|
|
|
|
|
|
|
|
|
|
Assert.That(edges.Select(edge => edge.Target.Value), Is.EqualTo(new[] { "leaf-a-3", "leaf-a-4" }));
|
|
|
|
|
Assert.That(edges.All(edge => edge.Payload == null), Is.True);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ReferencesOfEnumeratesASingleReferenceToAnAbstractTarget()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
|
|
|
|
|
var citer = registry.GetRequired<FixtureCiter>();
|
|
|
|
|
var document = new FixtureCiter { Design = new CultRecordRef<FixtureMiddle>(new CultRecordKey("leaf-b-1")) };
|
|
|
|
|
|
|
|
|
|
var edges = citer.ReferencesOf(document, "Design").ToArray();
|
|
|
|
|
|
|
|
|
|
Assert.That(edges.Single().Target.Value, Is.EqualTo("leaf-b-1"));
|
|
|
|
|
Assert.That(edges.Single().Payload, Is.Null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// D9: the abstract middle carries no [CultDocument] and no catalog entry (TargetSchemaName is
|
|
|
|
|
// null for exactly this reference), so the leaf set must be resolved by assignability against
|
|
|
|
|
// the live registry, not read off a persisted name.
|
|
|
|
|
[Test]
|
|
|
|
|
public void ResolveTargetLeavesFindsBothLeavesUnderAnAbstractMiddleWithNoSchemaOfItsOwn()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB), typeof(FixtureCiter) });
|
|
|
|
|
var citer = registry.GetRequired<FixtureCiter>();
|
|
|
|
|
var designMember = citer.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureCiter.Design));
|
|
|
|
|
|
|
|
|
|
Assert.That(designMember.TargetType, Is.EqualTo(typeof(FixtureMiddle)));
|
|
|
|
|
Assert.That(typeof(FixtureMiddle).GetCustomAttribute<CultDocumentAttribute>(), Is.Null);
|
|
|
|
|
|
|
|
|
|
var leaves = registry.ResolveTargetLeaves(designMember.TargetType!);
|
|
|
|
|
|
|
|
|
|
Assert.That(leaves.Select(d => d.DocumentType).OrderBy(t => t.Name),
|
|
|
|
|
Is.EqualTo(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB) }.OrderBy(t => t.Name)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void ResolveTargetLeavesSeesALeafRegisteredAfterAnEarlierDescriptorWasBuilt()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureCiter), typeof(FixtureLeafA) });
|
|
|
|
|
var citer = registry.GetRequired<FixtureCiter>();
|
|
|
|
|
var designMember = citer.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureCiter.Design));
|
|
|
|
|
|
|
|
|
|
var before = registry.ResolveTargetLeaves(designMember.TargetType!);
|
|
|
|
|
Assert.That(before.Select(d => d.DocumentType), Does.Not.Contain(typeof(FixtureLeafB)));
|
|
|
|
|
|
|
|
|
|
registry.GetRequired<FixtureLeafB>();
|
|
|
|
|
var after = registry.ResolveTargetLeaves(designMember.TargetType!);
|
|
|
|
|
Assert.That(after.Select(d => d.DocumentType), Does.Contain(typeof(FixtureLeafB)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// D8's inherited-alias case, read through the public surface: mass is declared once on the
|
|
|
|
|
// abstract middle and reachable on both leaves.
|
|
|
|
|
[Test]
|
|
|
|
|
public void TryGetIndexValueAndNumberReadAnInheritedAliasOnEachLeaf()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA), typeof(FixtureLeafB) });
|
|
|
|
|
var leafA = registry.GetRequired<FixtureLeafA>();
|
|
|
|
|
var documentA = new FixtureLeafA { Name = "sword", Mass = 3.5f };
|
|
|
|
|
|
|
|
|
|
Assert.That(leafA.TryGetIndexValue(documentA, "mass", out var text), Is.True);
|
|
|
|
|
Assert.That(text, Is.EqualTo("3.5"));
|
|
|
|
|
Assert.That(leafA.TryGetIndexNumber(documentA, "mass", out var number), Is.True);
|
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
|
|
|
Assert.That(number, Is.EqualTo("3.5"));
|
CultNet selection cut1, commit 0: the cache's public read surface
docs/cultnet-selection-cut.md section 6/7/10: the reflection loop at
CultNetDocumentRegistry.cs:325-342 exists only because nothing outside
GameCult.Caching can read a declared index value, reference, or numeric
member (Members/IndexAccessors are internal, no InternalsVisibleTo to
Networking). This adds the one public door instead: CultDocumentMemberView,
CultDocumentDescriptor.DeclaredMembers/TryGetIndexValue/TryGetIndexNumber/
ReferencesOf, and CultDocumentRegistry.ResolveTargetLeaves (D9, computed live
against the registry, never cached or persisted).
Two registration refusals land beside DiscoverMembers' existing
DuplicateSlotMessage/HiddenMemberMessage: D10 renames the ArgumentException
ToDictionary already threw for two differently-named members sharing one
index alias into a rejection that names the schema, both members and the
alias; D11 refuses a reference member whose CLR shape the cache cannot walk
(many:false needs CultRecordRef<T>, many:true needs
IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>) instead of
silently answering it as unreadable later.
GameCult.Networking is untouched; the evaluator that will read this surface
does not exist yet (commit 1).
Verification: dotnet test tests/GameCult.Caching.Tests - 188 passed (176
existing + 12 new), 0 failed. New tests: TryGetIndexValue/TryGetIndexNumber
reading an inherited alias; ReferencesOf over a single/many-list/many-dictionary
reference, including two dictionary entries with different payload bytes
(S19); ResolveTargetLeaves against an abstract target with no [CultDocument]
of its own (D9), including a leaf registered after an earlier descriptor was
built; IsNumeric agreement for a nullable numeric and a decimal member (S22);
the two new registration refusals, dynamically emitted so registry discovery
by attribute scan cannot see the rejected shapes.
scripts/mutate-cultnet-selection-caching.ps1: a dedicated dotnet/NUnit
mutation runner (the shared tools/eureka-mutations.ps1 harness only speaks
cargo; scripts/mutate-cultmesh.mjs is the QUIC campaign's file and this
campaign does not edit it, per the map's section 10). Byte-exact restore
verified by SHA-256, a sidecar for crash recovery, M0 no-op control, one
anchor match required per entry. Six entries (D8/S22, D10, D11 - revert and
loosening each) all KILLED; final restore verified green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 01:57:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void TryGetIndexValueRefusesAnUndeclaredAlias()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA) });
|
|
|
|
|
var leafA = registry.GetRequired<FixtureLeafA>();
|
|
|
|
|
|
|
|
|
|
Assert.That(leafA.TryGetIndexValue(new FixtureLeafA(), "not_declared", out _), Is.False);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// S22: IsNumeric is derived from the CLR set (sbyte..decimal, Nullable<T> unwrapped) and must
|
|
|
|
|
// agree for a nullable numeric member and a decimal member - the two authorities D8 names.
|
|
|
|
|
[Test]
|
|
|
|
|
public void IsNumericAgreesForANullableNumericMemberAndADecimalMember()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafB) });
|
|
|
|
|
var leafB = registry.GetRequired<FixtureLeafB>();
|
|
|
|
|
var weight = leafB.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureLeafB.Weight));
|
|
|
|
|
|
|
|
|
|
Assert.That(weight.IsNumeric, Is.True);
|
|
|
|
|
var document = new FixtureLeafB { Weight = 4.25m };
|
|
|
|
|
Assert.That(leafB.TryGetIndexNumber(document, "weight", out var number), Is.True);
|
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
|
|
|
Assert.That(number, Is.EqualTo("4.25"));
|
CultNet selection cut1, commit 0: the cache's public read surface
docs/cultnet-selection-cut.md section 6/7/10: the reflection loop at
CultNetDocumentRegistry.cs:325-342 exists only because nothing outside
GameCult.Caching can read a declared index value, reference, or numeric
member (Members/IndexAccessors are internal, no InternalsVisibleTo to
Networking). This adds the one public door instead: CultDocumentMemberView,
CultDocumentDescriptor.DeclaredMembers/TryGetIndexValue/TryGetIndexNumber/
ReferencesOf, and CultDocumentRegistry.ResolveTargetLeaves (D9, computed live
against the registry, never cached or persisted).
Two registration refusals land beside DiscoverMembers' existing
DuplicateSlotMessage/HiddenMemberMessage: D10 renames the ArgumentException
ToDictionary already threw for two differently-named members sharing one
index alias into a rejection that names the schema, both members and the
alias; D11 refuses a reference member whose CLR shape the cache cannot walk
(many:false needs CultRecordRef<T>, many:true needs
IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>) instead of
silently answering it as unreadable later.
GameCult.Networking is untouched; the evaluator that will read this surface
does not exist yet (commit 1).
Verification: dotnet test tests/GameCult.Caching.Tests - 188 passed (176
existing + 12 new), 0 failed. New tests: TryGetIndexValue/TryGetIndexNumber
reading an inherited alias; ReferencesOf over a single/many-list/many-dictionary
reference, including two dictionary entries with different payload bytes
(S19); ResolveTargetLeaves against an abstract target with no [CultDocument]
of its own (D9), including a leaf registered after an earlier descriptor was
built; IsNumeric agreement for a nullable numeric and a decimal member (S22);
the two new registration refusals, dynamically emitted so registry discovery
by attribute scan cannot see the rejected shapes.
scripts/mutate-cultnet-selection-caching.ps1: a dedicated dotnet/NUnit
mutation runner (the shared tools/eureka-mutations.ps1 harness only speaks
cargo; scripts/mutate-cultmesh.mjs is the QUIC campaign's file and this
campaign does not edit it, per the map's section 10). Byte-exact restore
verified by SHA-256, a sidecar for crash recovery, M0 no-op control, one
anchor match required per entry. Six entries (D8/S22, D10, D11 - revert and
loosening each) all KILLED; final restore verified green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 01:57:35 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void IsNumericIsFalseForAStringAlias()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafA) });
|
|
|
|
|
var leafA = registry.GetRequired<FixtureLeafA>();
|
|
|
|
|
var name = leafA.DeclaredMembers.Single(member => member.MemberName == nameof(FixtureLeafA.Name));
|
|
|
|
|
|
|
|
|
|
Assert.That(name.IsNumeric, Is.False);
|
|
|
|
|
}
|
|
|
|
|
|
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 row side renders a
|
|
|
|
|
// member's exact canonical decimal - never a double. A long past 2^53 (where double loses
|
|
|
|
|
// integer precision) is the named boundary: rendering it through float64 would silently produce
|
|
|
|
|
// "9007199254740992" for 9007199254740993, and the two would then compare equal on the wire.
|
|
|
|
|
[Test]
|
|
|
|
|
public void TryGetIndexNumberRendersALongPast2Pow53ExactlyNotThroughFloat64()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) });
|
|
|
|
|
var leaf = registry.GetRequired<FixtureLeafNumbers>();
|
|
|
|
|
var document = new FixtureLeafNumbers { BigInteger = 9007199254740993L };
|
|
|
|
|
|
|
|
|
|
Assert.That(leaf.TryGetIndexNumber(document, "big_integer", out var number), Is.True);
|
|
|
|
|
Assert.That(number, Is.EqualTo("9007199254740993"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void TryGetIndexNumberStripsTrailingZerosFromADecimal()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) });
|
|
|
|
|
var leaf = registry.GetRequired<FixtureLeafNumbers>();
|
|
|
|
|
var document = new FixtureLeafNumbers { TrimmedDecimal = 13.500m };
|
|
|
|
|
|
|
|
|
|
Assert.That(leaf.TryGetIndexNumber(document, "trimmed_decimal", out var number), Is.True);
|
|
|
|
|
Assert.That(number, Is.EqualTo("13.5"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The shortest round-trip form .NET renders for a double/float outside a middle magnitude range
|
|
|
|
|
// uses exponent notation ("1E+21", "1E-07"); the canonical wire form never does (Q-J).
|
|
|
|
|
[Test]
|
|
|
|
|
public void TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) });
|
|
|
|
|
var leaf = registry.GetRequired<FixtureLeafNumbers>();
|
|
|
|
|
var wide = new FixtureLeafNumbers { WideDouble = 1e21 };
|
|
|
|
|
var tiny = new FixtureLeafNumbers { TinyDouble = 1e-7 };
|
|
|
|
|
|
|
|
|
|
Assert.That(leaf.TryGetIndexNumber(wide, "wide_double", out var wideNumber), Is.True);
|
|
|
|
|
Assert.That(wideNumber, Does.Not.Contain("E"));
|
|
|
|
|
Assert.That(wideNumber, Is.EqualTo("1000000000000000000000"));
|
|
|
|
|
|
|
|
|
|
Assert.That(leaf.TryGetIndexNumber(tiny, "tiny_double", out var tinyNumber), Is.True);
|
|
|
|
|
Assert.That(tinyNumber, Does.Not.Contain("E"));
|
2026-09-22 09:39:36 +02:00
|
|
|
// R-D (docs/cultnet-selection-cut.md): the exact decimal expansion, not a shortest
|
|
|
|
|
// round-trip form. 1e-7 is not exactly representable as a double; its nearest double's
|
|
|
|
|
// exact value is this - the old assertion pinned "0.0000001", the shortest form a
|
|
|
|
|
// pre-R-D render would have produced instead.
|
|
|
|
|
Assert.That(tinyNumber, Is.EqualTo("0.0000000999999999999999954748111825886258685613938723690807819366455078125"));
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void TryGetIndexNumberReturnsFalseForNaNAndInfinity()
|
|
|
|
|
{
|
|
|
|
|
var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FixtureLeafNumbers) });
|
|
|
|
|
var leaf = registry.GetRequired<FixtureLeafNumbers>();
|
|
|
|
|
var nan = new FixtureLeafNumbers { WideDouble = double.NaN };
|
|
|
|
|
var infinite = new FixtureLeafNumbers { WideDouble = double.PositiveInfinity };
|
|
|
|
|
|
|
|
|
|
Assert.That(leaf.TryGetIndexNumber(nan, "wide_double", out _), Is.False);
|
|
|
|
|
Assert.That(leaf.TryGetIndexNumber(infinite, "wide_double", out _), Is.False);
|
|
|
|
|
}
|
|
|
|
|
|
CultNet selection cut1, commit 0: the cache's public read surface
docs/cultnet-selection-cut.md section 6/7/10: the reflection loop at
CultNetDocumentRegistry.cs:325-342 exists only because nothing outside
GameCult.Caching can read a declared index value, reference, or numeric
member (Members/IndexAccessors are internal, no InternalsVisibleTo to
Networking). This adds the one public door instead: CultDocumentMemberView,
CultDocumentDescriptor.DeclaredMembers/TryGetIndexValue/TryGetIndexNumber/
ReferencesOf, and CultDocumentRegistry.ResolveTargetLeaves (D9, computed live
against the registry, never cached or persisted).
Two registration refusals land beside DiscoverMembers' existing
DuplicateSlotMessage/HiddenMemberMessage: D10 renames the ArgumentException
ToDictionary already threw for two differently-named members sharing one
index alias into a rejection that names the schema, both members and the
alias; D11 refuses a reference member whose CLR shape the cache cannot walk
(many:false needs CultRecordRef<T>, many:true needs
IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>) instead of
silently answering it as unreadable later.
GameCult.Networking is untouched; the evaluator that will read this surface
does not exist yet (commit 1).
Verification: dotnet test tests/GameCult.Caching.Tests - 188 passed (176
existing + 12 new), 0 failed. New tests: TryGetIndexValue/TryGetIndexNumber
reading an inherited alias; ReferencesOf over a single/many-list/many-dictionary
reference, including two dictionary entries with different payload bytes
(S19); ResolveTargetLeaves against an abstract target with no [CultDocument]
of its own (D9), including a leaf registered after an earlier descriptor was
built; IsNumeric agreement for a nullable numeric and a decimal member (S22);
the two new registration refusals, dynamically emitted so registry discovery
by attribute scan cannot see the rejected shapes.
scripts/mutate-cultnet-selection-caching.ps1: a dedicated dotnet/NUnit
mutation runner (the shared tools/eureka-mutations.ps1 harness only speaks
cargo; scripts/mutate-cultmesh.mjs is the QUIC campaign's file and this
campaign does not edit it, per the map's section 10). Byte-exact restore
verified by SHA-256, a sidecar for crash recovery, M0 no-op control, one
anchor match required per entry. Six entries (D8/S22, D10, D11 - revert and
loosening each) all KILLED; final restore verified green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 01:57:35 +02:00
|
|
|
// D10 and D11 registration refusals: emitted into a dynamic assembly, invisible to registry
|
|
|
|
|
// discovery by attribute scan, exactly as DocumentShapeTests does for every other shape refusal.
|
|
|
|
|
private static readonly ModuleBuilder Rejected = AssemblyBuilder
|
|
|
|
|
.DefineDynamicAssembly(new AssemblyName("RejectedSelectionShapes"), AssemblyBuilderAccess.Run)
|
|
|
|
|
.DefineDynamicModule("RejectedSelectionShapes");
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName()
|
|
|
|
|
{
|
|
|
|
|
var type = Document("DupAlias");
|
|
|
|
|
Field(type, "First", typeof(string), Key(0), IndexAttr("shared"));
|
|
|
|
|
Field(type, "Second", typeof(string), Key(1), IndexAttr("shared"));
|
|
|
|
|
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
|
|
|
|
|
|
|
|
|
|
Assert.That(message, Does.Contain("DupAlias"));
|
|
|
|
|
Assert.That(message, Does.Contain("First"));
|
|
|
|
|
Assert.That(message, Does.Contain("Second"));
|
|
|
|
|
Assert.That(message, Does.Contain("shared"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-23 07:17:21 +02:00
|
|
|
// R-AL/SM-6: a plain data member's bare name (it declares no [CultIndex], so its role in
|
|
|
|
|
// ReferencesOf/ReferenceMembers falls back to its own MemberName) colliding with a reference
|
|
|
|
|
// member's explicit alias. D10 above only groups members that declare an alias and misses
|
|
|
|
|
// this; before the fix this registered without error and then every hop through the
|
|
|
|
|
// reference member threw ArgumentNullException at resolution time (probed by Soul).
|
|
|
|
|
[Test]
|
|
|
|
|
public void DataMemberNameCollidingWithAReferenceMembersAliasIsRefusedAtRegistration()
|
|
|
|
|
{
|
|
|
|
|
var type = Document("OwnerCollision");
|
|
|
|
|
Field(type, "Owner", typeof(string), Key(0));
|
|
|
|
|
Field(type, "Ref", typeof(CultRecordRef<FixtureLeafA>), Key(1), IndexAttr("Owner"));
|
|
|
|
|
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
|
|
|
|
|
|
|
|
|
|
Assert.That(message, Does.Contain("OwnerCollision"));
|
|
|
|
|
Assert.That(message, Does.Contain("Owner"));
|
|
|
|
|
Assert.That(message, Does.Contain("Ref"));
|
|
|
|
|
}
|
|
|
|
|
|
CultNet selection cut1, commit 0: the cache's public read surface
docs/cultnet-selection-cut.md section 6/7/10: the reflection loop at
CultNetDocumentRegistry.cs:325-342 exists only because nothing outside
GameCult.Caching can read a declared index value, reference, or numeric
member (Members/IndexAccessors are internal, no InternalsVisibleTo to
Networking). This adds the one public door instead: CultDocumentMemberView,
CultDocumentDescriptor.DeclaredMembers/TryGetIndexValue/TryGetIndexNumber/
ReferencesOf, and CultDocumentRegistry.ResolveTargetLeaves (D9, computed live
against the registry, never cached or persisted).
Two registration refusals land beside DiscoverMembers' existing
DuplicateSlotMessage/HiddenMemberMessage: D10 renames the ArgumentException
ToDictionary already threw for two differently-named members sharing one
index alias into a rejection that names the schema, both members and the
alias; D11 refuses a reference member whose CLR shape the cache cannot walk
(many:false needs CultRecordRef<T>, many:true needs
IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>) instead of
silently answering it as unreadable later.
GameCult.Networking is untouched; the evaluator that will read this surface
does not exist yet (commit 1).
Verification: dotnet test tests/GameCult.Caching.Tests - 188 passed (176
existing + 12 new), 0 failed. New tests: TryGetIndexValue/TryGetIndexNumber
reading an inherited alias; ReferencesOf over a single/many-list/many-dictionary
reference, including two dictionary entries with different payload bytes
(S19); ResolveTargetLeaves against an abstract target with no [CultDocument]
of its own (D9), including a leaf registered after an earlier descriptor was
built; IsNumeric agreement for a nullable numeric and a decimal member (S22);
the two new registration refusals, dynamically emitted so registry discovery
by attribute scan cannot see the rejected shapes.
scripts/mutate-cultnet-selection-caching.ps1: a dedicated dotnet/NUnit
mutation runner (the shared tools/eureka-mutations.ps1 harness only speaks
cargo; scripts/mutate-cultmesh.mjs is the QUIC campaign's file and this
campaign does not edit it, per the map's section 10). Byte-exact restore
verified by SHA-256, a sidecar for crash recovery, M0 no-op control, one
anchor match required per entry. Six entries (D8/S22, D10, D11 - revert and
loosening each) all KILLED; final restore verified green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 01:57:35 +02:00
|
|
|
[Test]
|
|
|
|
|
public void ManyReferenceOfAnElementTypeThatIsNotACultRecordRefIsRefused()
|
|
|
|
|
{
|
|
|
|
|
var type = Document("BadMany");
|
|
|
|
|
Field(type, "Values", typeof(List<int>), Key(0), ReferenceAttr(null, many: true));
|
|
|
|
|
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
|
|
|
|
|
|
|
|
|
|
Assert.That(message, Does.Contain("BadMany"));
|
|
|
|
|
Assert.That(message, Does.Contain("Values"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Test]
|
|
|
|
|
public void SingleReferenceWhoseMemberTypeIsNotACultRecordRefIsRefused()
|
|
|
|
|
{
|
|
|
|
|
var type = Document("BadSingle");
|
|
|
|
|
Field(type, "Target", typeof(int), Key(0), ReferenceAttr(typeof(FixtureLeafA), many: false));
|
|
|
|
|
var message = Assert.Throws<InvalidOperationException>(() => CultDocumentRegistry.ForTypes(new[] { type.CreateType()! }))!.Message;
|
|
|
|
|
|
|
|
|
|
Assert.That(message, Does.Contain("BadSingle"));
|
|
|
|
|
Assert.That(message, Does.Contain("Target"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static TypeBuilder Document(string name)
|
|
|
|
|
{
|
|
|
|
|
var type = Rejected.DefineType(name, TypeAttributes.Public);
|
|
|
|
|
type.SetCustomAttribute(new CustomAttributeBuilder(
|
|
|
|
|
typeof(CultDocumentAttribute).GetConstructor(new[] { typeof(string), typeof(string) })!,
|
|
|
|
|
new object[] { "selection.reject." + name, "selection.reject." + name + ".v1" }));
|
|
|
|
|
type.SetCustomAttribute(new CustomAttributeBuilder(typeof(MessagePackObjectAttribute).GetConstructor(new[] { typeof(bool) })!, new object[] { false }));
|
|
|
|
|
return type;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void Field(TypeBuilder type, string name, Type fieldType, params CustomAttributeBuilder[] attributes)
|
|
|
|
|
{
|
|
|
|
|
var field = type.DefineField(name, fieldType, FieldAttributes.Public);
|
|
|
|
|
foreach (var attribute in attributes) field.SetCustomAttribute(attribute);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static CustomAttributeBuilder Key(int slot) =>
|
|
|
|
|
new(typeof(KeyAttribute).GetConstructor(new[] { typeof(int) })!, new object[] { slot });
|
|
|
|
|
|
|
|
|
|
private static CustomAttributeBuilder IndexAttr(string alias) =>
|
|
|
|
|
new(typeof(CultIndexAttribute).GetConstructor(new[] { typeof(string) })!, new object?[] { alias });
|
|
|
|
|
|
|
|
|
|
private static CustomAttributeBuilder ReferenceAttr(Type? targetType, bool many) =>
|
|
|
|
|
new(typeof(CultReferenceAttribute).GetConstructor(new[] { typeof(Type), typeof(bool) })!, new object?[] { targetType, many });
|
|
|
|
|
|
|
|
|
|
public abstract class FixtureMiddle
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
[CultIndex("mass")]
|
|
|
|
|
public float Mass;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[CultDocument("selection.fixture.leaf_a", "selection.fixture.leaf_a.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
public sealed class FixtureLeafA : FixtureMiddle
|
|
|
|
|
{
|
|
|
|
|
[Key(2)]
|
|
|
|
|
public int Extra;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[CultDocument("selection.fixture.leaf_b", "selection.fixture.leaf_b.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
public sealed class FixtureLeafB : FixtureMiddle
|
|
|
|
|
{
|
|
|
|
|
[Key(2)]
|
|
|
|
|
[CultIndex("weight")]
|
|
|
|
|
public decimal? Weight;
|
|
|
|
|
}
|
|
|
|
|
|
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: a fixture dedicated to the row-side canonical rendering rules, independent of the
|
|
|
|
|
// abstract-middle/inheritance fixtures above so it can carry a long, a decimal and two doubles
|
|
|
|
|
// without disturbing the schemas other tests in this file pin.
|
|
|
|
|
[CultDocument("selection.fixture.leaf_numbers", "selection.fixture.leaf_numbers.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
public sealed class FixtureLeafNumbers
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
[CultIndex("big_integer")]
|
|
|
|
|
public long BigInteger;
|
|
|
|
|
|
|
|
|
|
[Key(2)]
|
|
|
|
|
[CultIndex("trimmed_decimal")]
|
|
|
|
|
public decimal TrimmedDecimal;
|
|
|
|
|
|
|
|
|
|
[Key(3)]
|
|
|
|
|
[CultIndex("wide_double")]
|
|
|
|
|
public double WideDouble;
|
|
|
|
|
|
|
|
|
|
[Key(4)]
|
|
|
|
|
[CultIndex("tiny_double")]
|
|
|
|
|
public double TinyDouble;
|
|
|
|
|
}
|
|
|
|
|
|
CultNet selection cut1, commit 0: the cache's public read surface
docs/cultnet-selection-cut.md section 6/7/10: the reflection loop at
CultNetDocumentRegistry.cs:325-342 exists only because nothing outside
GameCult.Caching can read a declared index value, reference, or numeric
member (Members/IndexAccessors are internal, no InternalsVisibleTo to
Networking). This adds the one public door instead: CultDocumentMemberView,
CultDocumentDescriptor.DeclaredMembers/TryGetIndexValue/TryGetIndexNumber/
ReferencesOf, and CultDocumentRegistry.ResolveTargetLeaves (D9, computed live
against the registry, never cached or persisted).
Two registration refusals land beside DiscoverMembers' existing
DuplicateSlotMessage/HiddenMemberMessage: D10 renames the ArgumentException
ToDictionary already threw for two differently-named members sharing one
index alias into a rejection that names the schema, both members and the
alias; D11 refuses a reference member whose CLR shape the cache cannot walk
(many:false needs CultRecordRef<T>, many:true needs
IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>) instead of
silently answering it as unreadable later.
GameCult.Networking is untouched; the evaluator that will read this surface
does not exist yet (commit 1).
Verification: dotnet test tests/GameCult.Caching.Tests - 188 passed (176
existing + 12 new), 0 failed. New tests: TryGetIndexValue/TryGetIndexNumber
reading an inherited alias; ReferencesOf over a single/many-list/many-dictionary
reference, including two dictionary entries with different payload bytes
(S19); ResolveTargetLeaves against an abstract target with no [CultDocument]
of its own (D9), including a leaf registered after an earlier descriptor was
built; IsNumeric agreement for a nullable numeric and a decimal member (S22);
the two new registration refusals, dynamically emitted so registry discovery
by attribute scan cannot see the rejected shapes.
scripts/mutate-cultnet-selection-caching.ps1: a dedicated dotnet/NUnit
mutation runner (the shared tools/eureka-mutations.ps1 harness only speaks
cargo; scripts/mutate-cultmesh.mjs is the QUIC campaign's file and this
campaign does not edit it, per the map's section 10). Byte-exact restore
verified by SHA-256, a sidecar for crash recovery, M0 no-op control, one
anchor match required per entry. Six entries (D8/S22, D10, D11 - revert and
loosening each) all KILLED; final restore verified green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 01:57:35 +02:00
|
|
|
[CultDocument("selection.fixture.citer", "selection.fixture.citer.v1")]
|
|
|
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
|
|
|
public sealed class FixtureCiter
|
|
|
|
|
{
|
|
|
|
|
[Key(0)]
|
|
|
|
|
[CultName]
|
|
|
|
|
public string Name = string.Empty;
|
|
|
|
|
|
|
|
|
|
[Key(1)]
|
|
|
|
|
[CultReference(typeof(FixtureMiddle))]
|
|
|
|
|
public CultRecordRef<FixtureMiddle> Design;
|
|
|
|
|
|
|
|
|
|
[Key(2)]
|
|
|
|
|
[CultReference(typeof(FixtureLeafA), many: true)]
|
|
|
|
|
public Dictionary<CultRecordRef<FixtureLeafA>, float> Components = new();
|
|
|
|
|
|
|
|
|
|
[Key(3)]
|
|
|
|
|
[CultReference(typeof(FixtureLeafA), many: true)]
|
|
|
|
|
public List<CultRecordRef<FixtureLeafA>> Related = new();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|