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>
This commit is contained in:
Meta Vens 2026-09-22 01:57:35 +02:00
commit e420410fe9
3 changed files with 782 additions and 13 deletions

View file

@ -0,0 +1,198 @@
# Mutation check for CultNet typed selection, Cut 1, commit 0: the cache's
# public read surface (docs/cultnet-selection-cut.md, section 6, D8/D10/D11).
#
# This is a dedicated runner for src/GameCult.Caching, not the shared
# tools/eureka-mutations.ps1 harness: that harness's Invoke-Cargo parses
# `cargo test` output and appends `-- --exact <test>`, and is not usable
# against `dotnet test`/NUnit. It is also not scripts/mutate-cultmesh.mjs -
# that file is the QUIC campaign's, live and dirty in this tree, and this
# campaign does not edit it (docs/cultnet-selection-cut.md, section 10: "this
# campaign adds its entries in its own file ... and does not edit the QUIC
# campaign's targets - Self decides the exact split at landing").
#
# Same discipline as both: every entry mutates CultCache.cs, is applied as a
# byte-exact splice against one anchor that must match exactly once, restored
# from the original bytes (never from git) with a SHA-256 check, and a
# sidecar (<target>.mutation-original) protects a hard kill mid-mutation. M0
# is a no-op control: the target round-trips through the read/write path
# unchanged and the unmutated suite is green, before any entry runs.
#
# powershell -File scripts/mutate-cultnet-selection-caching.ps1
#
# Exit code: 0 when every entry's revert and loosening (where one is named)
# killed the test named in Killer, non-zero otherwise. Output for a run that
# did not go green names which entry and which mutant survived.
$ErrorActionPreference = 'Stop'
$repo = Resolve-Path (Join-Path $PSScriptRoot '..')
$target = Join-Path $repo 'src\GameCult.Caching\CultCache.cs'
$sidecar = "$target.mutation-original"
$testProject = Join-Path $repo 'tests\GameCult.Caching.Tests\GameCult.Caching.Tests.csproj'
$utf8 = [System.Text.UTF8Encoding]::new($false)
function Read-Bytes([string]$path) { [System.IO.File]::ReadAllBytes($path) }
function Get-BytesHash([byte[]]$bytes) {
([System.Security.Cryptography.SHA256]::Create().ComputeHash($bytes) | ForEach-Object { $_.ToString('x2') }) -join ''
}
function Write-Text([string]$path, [string]$text) {
[System.IO.File]::WriteAllBytes($path, [byte[]]($utf8.GetPreamble() + $utf8.GetBytes($text)))
}
function Run-Tests([string]$filter) {
$args = @('test', $testProject, '-c', 'Debug', '--filter', $filter, '--nologo')
$output = & dotnet @args 2>&1 | Out-String
Write-Host $output
$passedMatch = [regex]::Match($output, 'Passed:\s+(\d+)')
$failedMatch = [regex]::Match($output, 'Failed:\s+(\d+)')
$compiled = -not ($output -match 'error CS\d+')
$passedLine = $output -match 'Passed!'
$failedLine = $output -match 'Failed!'
[pscustomobject]@{
Compiled = $compiled
Green = $compiled -and $passedLine -and -not $failedLine -and $passedMatch.Success -and [int]$passedMatch.Groups[1].Value -ge 1
Passed = if ($passedMatch.Success) { [int]$passedMatch.Groups[1].Value } else { 0 }
Failed = if ($failedMatch.Success) { [int]$failedMatch.Groups[1].Value } else { 0 }
}
}
# --- Repair a run that died mid-mutation ------------------------------------
if (Test-Path -LiteralPath $sidecar) {
$original = Read-Bytes $sidecar
[System.IO.File]::WriteAllBytes($target, $original)
Remove-Item -LiteralPath $sidecar -Force
Write-Host "Repaired: a previous run left $sidecar; $target restored from it and the sidecar removed."
}
# --- M0: no-op control -------------------------------------------------------
Write-Host '--- M0: no-op control'
$originalBytes = Read-Bytes $target
$originalHash = Get-BytesHash $originalBytes
$originalText = $utf8.GetString($originalBytes)
$reencoded = [byte[]]($utf8.GetPreamble() + $utf8.GetBytes($originalText))
if ((Get-BytesHash $reencoded) -ne $originalHash) {
throw "harness broken: $target does not round-trip through the UTF-8 read/write path byte for byte. Nothing was written and no entry ran."
}
[System.IO.File]::WriteAllBytes($sidecar, $originalBytes)
try {
Write-Text $target $originalText
if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) {
throw "harness broken: writing $target back through the harness path changed its bytes."
}
}
finally {
[System.IO.File]::WriteAllBytes($target, $originalBytes)
if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) { throw "RESTORE FAILED after M0; original SHA-256 $originalHash is kept in $sidecar." }
Remove-Item -LiteralPath $sidecar -Force
}
$control = Run-Tests 'FullyQualifiedName~CultDocumentSelectionSurfaceTests'
if (-not $control.Green) {
throw "harness broken: the caching suite is not green unmutated (compiled=$($control.Compiled) passed=$($control.Passed) failed=$($control.Failed)). No entry ran."
}
Write-Host "M0: green, $($control.Passed) passed"
# --- Entries -----------------------------------------------------------------
$entries = @(
[pscustomobject]@{
Id = 'CACHE-D8-Revert'
Rule = 'D8/S22: IsNumeric is derived from the closed CLR numeric set; a decimal-typed member must be numeric.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.IsNumericAgreesForANullableNumericMemberAndADecimalMember'
Old = "typeof(int), typeof(uint), typeof(long), typeof(ulong),`n typeof(float), typeof(double), typeof(decimal)"
New = "typeof(int), typeof(uint), typeof(long), typeof(ulong),`n typeof(float), typeof(double)"
},
[pscustomobject]@{
Id = 'CACHE-D8-Loosening'
Rule = 'D8/S22: a string-typed member must not be treated as numeric.'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.IsNumericIsFalseForAStringAlias'
Old = "var underlying = Nullable.GetUnderlyingType(type) ?? type;`n return NumericClrTypes.Contains(underlying);"
New = "return true;"
},
[pscustomobject]@{
Id = 'CACHE-D10-Revert'
Rule = 'D10: two differently-named members sharing one index alias are refused by name at registration.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName'
Old = " // D10: two differently-named members that independently declare the same index alias. A`n // hiding member sharing one slot is already caught above; this is two distinct members whose`n // alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently.`n foreach (var group in keyed`n .Select(entry => (Entry: entry, Alias: ResolveIndexAlias(entry.Member)))`n .Where(pair => pair.Alias != null)`n .GroupBy(pair => pair.Alias, StringComparer.Ordinal)`n .Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1)`n .OrderBy(group => group.Key, StringComparer.Ordinal))`n {`n var pair = group.Take(2).ToArray();`n rejections.Add(DuplicateIndexAliasMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key!));`n }`n"
New = ""
},
[pscustomobject]@{
Id = 'CACHE-D10-Loosening'
Rule = 'D10: the collision groups by IndexAlias, not by Member.Name (which would refuse nothing HiddenMemberMessage does not already refuse).'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.DuplicateIndexAliasOnTwoDifferentlyNamedMembersIsRefusedByName'
Old = '.GroupBy(pair => pair.Alias, StringComparer.Ordinal)'
New = '.GroupBy(pair => pair.Entry.Member.Name, StringComparer.Ordinal)'
},
[pscustomobject]@{
Id = 'CACHE-D11-Revert'
Rule = 'D11: a dictionary reference is walked as edges keyed by CultRecordRef, not skipped as an unrecognised many-shape.'
Mutant = 'revert'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues'
Old = 'if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))'
New = 'if (false && TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))'
},
[pscustomobject]@{
Id = 'CACHE-D11-Loosening'
Rule = 'D11/S19: a dictionary reference edge carries its value as payload, not null (two entries with different payload bytes must compare unequal).'
Mutant = 'loosening'
Killer = 'FullyQualifiedName=GameCult.Caching.Tests.CultDocumentSelectionSurfaceTests.ReferencesOfEnumeratesADictionaryReferenceAsEdgesCarryingItsValues'
Old = 'results.Add((reference.Key, valueProperty.GetValue(entry)));'
New = 'results.Add((reference.Key, (object?)null));'
}
)
$exitCode = 0
foreach ($entry in $entries) {
Write-Host "--- $($entry.Id) ($($entry.Mutant)): $($entry.Rule)"
$text = $utf8.GetString((Read-Bytes $target))
$old = $entry.Old -replace "`r`n", "`n"
$normalized = $text -replace "`r`n", "`n"
$count = 0
$at = $normalized.IndexOf($old, [System.StringComparison]::Ordinal)
while ($at -ge 0) { $count++; $at = $normalized.IndexOf($old, $at + 1, [System.StringComparison]::Ordinal) }
if ($count -ne 1) {
throw "$($entry.Id): anchor matches $count times, expected exactly 1."
}
$mutatedNormalized = $normalized.Replace($old, ($entry.New -replace "`r`n", "`n"))
$eol = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" }
$mutatedText = $mutatedNormalized -replace "`n", $eol
$originalBytes = Read-Bytes $target
[System.IO.File]::WriteAllBytes($sidecar, $originalBytes)
$verdict = 'NO VERDICT'
try {
Write-Text $target $mutatedText
$run = Run-Tests $entry.Killer
if (-not $run.Compiled) {
$verdict = 'NO VERDICT (did not compile)'
}
elseif ($run.Failed -ge 1) {
$verdict = 'KILLED'
}
else {
$verdict = 'SURVIVED'
}
}
finally {
[System.IO.File]::WriteAllBytes($target, $originalBytes)
if ((Get-BytesHash (Read-Bytes $target)) -ne $originalHash) {
Write-Host "RESTORE FAILED: $target not restored; original SHA-256 $originalHash kept in $sidecar."
throw "restore failed for $($entry.Id)"
}
Remove-Item -LiteralPath $sidecar -Force
}
Write-Host "$($entry.Id): $verdict"
if ($verdict -ne 'KILLED') { $exitCode = 1 }
}
# Restore the suite to green once more as a final sanity check.
$final = Run-Tests 'FullyQualifiedName~CultDocumentSelectionSurfaceTests'
if (-not $final.Green) { throw "post-run sanity check: the caching suite is not green after restore (passed=$($final.Passed) failed=$($final.Failed))." }
Write-Host "Final: green, $($final.Passed) passed"
exit $exitCode

View file

@ -109,6 +109,46 @@ namespace GameCult.Caching
public CultSchemaMigrationReport Report { get; }
}
// A declared member's addressing shape, read-only: what a caller outside GameCult.Caching
// (the CultNet selection evaluator, chiefly) is allowed to know about a member without
// reflecting over the document itself. Built from the same declarations CultIndexAttribute
// and CultReferenceAttribute already recorded; carries no value.
public readonly struct CultDocumentMemberView
{
internal CultDocumentMemberView(
string memberName,
int slot,
string? indexAlias,
bool isName,
bool isReference,
bool isMany,
bool isNumeric,
Type? targetType)
{
MemberName = memberName;
Slot = slot;
IndexAlias = indexAlias;
IsName = isName;
IsReference = isReference;
IsMany = isMany;
IsNumeric = isNumeric;
TargetType = targetType;
}
public string MemberName { get; }
public int Slot { get; }
public string? IndexAlias { get; }
public bool IsName { get; }
public bool IsReference { get; }
public bool IsMany { get; }
public bool IsNumeric { get; }
// The reference's declared target: the type CultReferenceAttribute names, or CultRecordRef<T>'s T.
// Null when the member is not a reference. May be abstract (D9): the leaf set is resolved at
// evaluation by CultDocumentRegistry.ResolveTargetLeaves, never persisted here or in the catalog.
public Type? TargetType { get; }
}
public sealed class CultDocumentDescriptor
{
internal CultDocumentDescriptor(
@ -122,7 +162,8 @@ namespace GameCult.Caching
string? nameMember,
Func<object, string?>? nameAccessor,
IReadOnlyDictionary<string, Func<object, string>> indexAccessors,
IReadOnlyList<CultDocumentMemberDescriptor> members)
IReadOnlyList<CultDocumentMemberDescriptor> members,
IReadOnlyList<CultDocumentRegistry.PersistedMember> richMembers)
{
DocumentType = documentType;
SchemaName = schemaName;
@ -135,6 +176,18 @@ namespace GameCult.Caching
NameAccessor = nameAccessor;
IndexAccessors = indexAccessors;
Members = members;
RichMembers = richMembers;
DeclaredMembers = richMembers
.Select(member => new CultDocumentMemberView(
member.Member.Name,
member.Slot,
member.IndexAlias,
member.IsName,
member.IsReference,
member.IsMany,
member.IsNumeric,
member.TargetType))
.ToArray();
}
public Type DocumentType { get; }
@ -148,6 +201,80 @@ namespace GameCult.Caching
internal Func<object, string?>? NameAccessor { get; }
internal IReadOnlyDictionary<string, Func<object, string>> IndexAccessors { get; }
internal IReadOnlyList<CultDocumentMemberDescriptor> Members { get; }
internal IReadOnlyList<CultDocumentRegistry.PersistedMember> RichMembers { get; }
// The public read surface (CultNet selection cut 1, section 6): declared addressing, and the
// three ways to read a value through it. GameCult.Networking has no InternalsVisibleTo here on
// purpose - this is the only door, so nothing outside the cache decides what a member means.
public IReadOnlyList<CultDocumentMemberView> DeclaredMembers { get; }
public bool TryGetIndexValue(object document, string alias, out string? value)
{
if (document == null) throw new ArgumentNullException(nameof(document));
if (string.IsNullOrEmpty(alias))
{
value = null;
return false;
}
foreach (var member in RichMembers)
{
if (string.Equals(member.IndexAlias, alias, StringComparison.Ordinal))
{
value = member.GetterNullable(document);
return true;
}
}
value = null;
return false;
}
public bool TryGetIndexNumber(object document, string alias, out double value)
{
if (document == null) throw new ArgumentNullException(nameof(document));
if (!string.IsNullOrEmpty(alias))
{
foreach (var member in RichMembers)
{
if (string.Equals(member.IndexAlias, alias, StringComparison.Ordinal) && member.IsNumeric)
{
var number = member.NumberOf(document);
if (number.HasValue)
{
value = number.Value;
return true;
}
break;
}
}
}
value = default;
return false;
}
// The edges a declared reference member carries: one per CultRecordRef<T>, one per element of
// an IEnumerable<CultRecordRef<T>>, one per key of an IDictionary<CultRecordRef<T>, V> with the
// value as the payload (D11). role is the member's index alias, or its name when it has none.
public IEnumerable<(CultRecordKey Target, object? Payload)> ReferencesOf(object document, string role)
{
if (document == null) throw new ArgumentNullException(nameof(document));
if (string.IsNullOrEmpty(role)) return Array.Empty<(CultRecordKey, object?)>();
foreach (var member in RichMembers)
{
if (!member.IsReference) continue;
var memberRole = member.IndexAlias ?? member.Member.Name;
if (string.Equals(memberRole, role, StringComparison.Ordinal))
{
return member.ReferenceEnumerator!(document);
}
}
return Array.Empty<(CultRecordKey, object?)>();
}
public CultSchemaCatalogEntry ToCatalogEntry()
{
@ -254,6 +381,15 @@ namespace GameCult.Caching
public IEnumerable<CultDocumentDescriptor> AllDescriptors =>
_indexes.ByType.Values.OrderBy(d => d.SchemaName, StringComparer.Ordinal);
// D9: a reference's target set is every registered leaf assignable to its declared target type,
// computed against the registry as it stands right now - never cached, never persisted. A leaf
// registered after some other descriptor was built must still show up here.
public IReadOnlyList<CultDocumentDescriptor> ResolveTargetLeaves(Type targetType)
{
if (targetType == null) throw new ArgumentNullException(nameof(targetType));
return AllDescriptors.Where(d => targetType.IsAssignableFrom(d.DocumentType)).ToArray();
}
public void Refresh()
{
lock (_registrationGate)
@ -495,7 +631,8 @@ namespace GameCult.Caching
nameMember?.Member.Name,
nameMember?.GetterNullable,
indexAccessors,
descriptorMembers);
descriptorMembers,
members);
}
private static string BuildSemanticFingerprint(
@ -944,6 +1081,38 @@ namespace GameCult.Caching
rejections.Add(HiddenMemberMessage(type.Name, Qualified(pair[0].Member), Qualified(pair[1].Member)));
}
// D10: two differently-named members that independently declare the same index alias. A
// hiding member sharing one slot is already caught above; this is two distinct members whose
// alias map entry (BuildDescriptor's indexAccessors) would otherwise collide silently.
foreach (var group in keyed
.Select(entry => (Entry: entry, Alias: ResolveIndexAlias(entry.Member)))
.Where(pair => pair.Alias != null)
.GroupBy(pair => pair.Alias, StringComparer.Ordinal)
.Where(group => group.Select(pair => pair.Entry.Member.Name).Distinct().Count() > 1)
.OrderBy(group => group.Key, StringComparer.Ordinal))
{
var pair = group.Take(2).ToArray();
rejections.Add(DuplicateIndexAliasMessage(type.Name, Qualified(pair[0].Entry.Member), Qualified(pair[1].Entry.Member), group.Key!));
}
// D11: a member declared as a reference (by [CultReference] or a bare CultRecordRef<T> type)
// whose CLR shape the cache cannot walk. many:false needs CultRecordRef<T> itself; many:true
// needs IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>. A declaration the
// cache cannot read is refused here, not answered as an unreadable no-op reference later.
foreach (var candidate in keyed)
{
var referenceAttribute = candidate.Member.GetCustomAttribute<CultReferenceAttribute>();
var isBareRecordRef = IsCultRecordRefType(candidate.MemberType);
if (referenceAttribute == null && !isBareRecordRef)
continue;
var many = referenceAttribute?.Many ?? false;
var supported = many
? IsEnumerableOfRecordRef(candidate.MemberType) || TryGetDictionaryRefTypes(candidate.MemberType, out _, out _)
: isBareRecordRef;
if (!supported)
rejections.Add(UnsupportedReferenceShapeMessage(type.Name, Qualified(candidate.Member)));
}
if (rejections.Count > 0)
throw new InvalidOperationException(rejections[0]);
@ -992,6 +1161,74 @@ namespace GameCult.Caching
private static string Qualified(MemberInfo member) => member.DeclaringType!.Name.Split('`')[0] + "." + member.Name;
// Shared by DiscoverMembers' D10/D11 rejections and PersistedMember.FromMember, so registration
// refuses exactly the shapes the accessor it builds afterward could not have walked anyway.
private static string? ResolveIndexAlias(MemberInfo member)
{
var indexAttribute = member.GetCustomAttribute<CultIndexAttribute>();
if (indexAttribute == null)
return null;
return string.IsNullOrWhiteSpace(indexAttribute.Alias) ? member.Name : indexAttribute.Alias;
}
private static bool IsCultRecordRefType(Type type) =>
type.IsGenericType && type.GetGenericTypeDefinition() == typeof(CultRecordRef<>);
private static bool IsEnumerableOfRecordRef(Type type)
{
foreach (var candidate in type.GetInterfaces().Append(type))
{
if (candidate.IsGenericType
&& candidate.GetGenericTypeDefinition() == typeof(IEnumerable<>)
&& IsCultRecordRefType(candidate.GetGenericArguments()[0]))
return true;
}
return false;
}
private static bool TryGetDictionaryRefTypes(Type type, out PropertyInfo keyProperty, out PropertyInfo valueProperty)
{
foreach (var candidate in type.GetInterfaces().Append(type))
{
if (!candidate.IsGenericType || candidate.GetGenericTypeDefinition() != typeof(IDictionary<,>))
continue;
var arguments = candidate.GetGenericArguments();
if (!IsCultRecordRefType(arguments[0]))
continue;
var pairType = typeof(KeyValuePair<,>).MakeGenericType(arguments[0], arguments[1]);
keyProperty = pairType.GetProperty("Key")!;
valueProperty = pairType.GetProperty("Value")!;
return true;
}
keyProperty = null!;
valueProperty = null!;
return false;
}
private static readonly HashSet<Type> NumericClrTypes = new()
{
typeof(sbyte), typeof(byte), typeof(short), typeof(ushort),
typeof(int), typeof(uint), typeof(long), typeof(ulong),
typeof(float), typeof(double), typeof(decimal)
};
// D8's closed CLR numeric set: the sole authority for whether a comparison predicate may reach
// a member. The TypeName string persisted in the catalog is generated from this set (via
// CultSchemaTypeNames.FromType) and must never be hand-checked against separately - S22 pins it.
private static bool IsNumericClrType(Type type)
{
var underlying = Nullable.GetUnderlyingType(type) ?? type;
return NumericClrTypes.Contains(underlying);
}
private static string DuplicateIndexAliasMessage(string documentTypeName, string first, string second, string alias) =>
$"Cult document {documentTypeName} members {first} and {second} both declare index alias \"{alias}\"; an index alias must name one member.";
private static string UnsupportedReferenceShapeMessage(string documentTypeName, string member) =>
$"Cult document {documentTypeName} member {member} declares a reference the cache cannot walk; many:false needs CultRecordRef<T>, many:true needs IEnumerable<CultRecordRef<T>> or IDictionary<CultRecordRef<T>, V>.";
private static string NotMessagePackObjectMessage(string documentTypeName) =>
$"Cult document {documentTypeName} is not a MessagePack object; add [MessagePackObject] so MessagePack serializes its [Key(n)] members.";
@ -1047,7 +1284,9 @@ namespace GameCult.Caching
return "sha256:" + BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant();
}
private sealed class PersistedMember
// internal, not private: CultDocumentDescriptor (a separate top-level class) reads RichMembers
// through this type to build the public DeclaredMembers view and the three value accessors.
internal sealed class PersistedMember
{
public MemberInfo Member { get; set; } = default!;
public Type MemberType { get; set; } = default!;
@ -1057,9 +1296,25 @@ namespace GameCult.Caching
public bool IsReference { get; set; }
public bool IsMany { get; set; }
public string? TargetSchemaName { get; set; }
// The reference's declared target Type, held alongside TargetSchemaName (which is null for
// an abstract target - D9). CultDocumentRegistry.ResolveTargetLeaves needs the Type, not the
// name, to compute the leaf set by assignability.
public Type? TargetType { get; set; }
public bool IsNumeric { get; set; }
public Func<object, string> Getter { get; set; } = default!;
public Func<object, string?> GetterNullable { get; set; } = default!;
// D8: non-null exactly for the closed CLR numeric set (Nullable<T> unwrapped). Convert.ToDouble
// never loses a value CultCache's own supported numeric types can hold.
public Func<object, double?> NumberOf { get; set; } = default!;
// D11: null for a non-reference member; otherwise yields (target key, payload) per declared
// shape - one entry for a bare reference, one per element for a many-enumerable, one per key
// with its value as payload for a many-dictionary. Registration already refused every other
// shape (DiscoverMembers), so this is never called against something it cannot walk.
public Func<object, IEnumerable<(CultRecordKey Target, object? Payload)>>? ReferenceEnumerator { get; set; }
public static PersistedMember FromMember(
MemberInfo member,
Type memberType,
@ -1069,6 +1324,8 @@ namespace GameCult.Caching
var referenceAttribute = member.GetCustomAttribute<CultReferenceAttribute>();
var targetType = ResolveReferenceTarget(memberType, referenceAttribute?.TargetType);
var targetSchemaName = targetType?.GetCustomAttribute<CultDocumentAttribute>()?.SchemaName;
var isReference = targetType != null || referenceAttribute != null;
var many = referenceAttribute?.Many ?? false;
return new PersistedMember
{
Member = member,
@ -1076,25 +1333,65 @@ namespace GameCult.Caching
Slot = slot,
IsName = member.GetCustomAttribute<CultNameAttribute>() != null,
IndexAlias = ResolveIndexAlias(member),
IsReference = targetType != null || referenceAttribute != null,
IsMany = referenceAttribute?.Many ?? false,
IsReference = isReference,
IsMany = many,
TargetSchemaName = targetSchemaName,
TargetType = targetType,
IsNumeric = IsNumericClrType(memberType),
Getter = document => getValue(document)?.ToString() ?? string.Empty,
GetterNullable = document => getValue(document)?.ToString()
GetterNullable = document => getValue(document)?.ToString(),
NumberOf = document =>
{
var raw = getValue(document);
return raw == null ? null : (double?)Convert.ToDouble(raw, CultureInfo.InvariantCulture);
},
ReferenceEnumerator = isReference ? BuildReferenceEnumerator(memberType, many, getValue) : null
};
}
private static string? ResolveIndexAlias(MemberInfo member)
// Registration (DiscoverMembers' D11 check) already refused every shape but the three this
// builds an enumerator for; this does not re-refuse, it only classifies which of the three.
private static Func<object, IEnumerable<(CultRecordKey Target, object? Payload)>> BuildReferenceEnumerator(
Type memberType, bool many, Func<object, object?> getValue)
{
var indexAttribute = member.GetCustomAttribute<CultIndexAttribute>();
if (indexAttribute == null)
if (!many)
{
return null;
return document =>
getValue(document) is ICultRecordRef reference
? new[] { (reference.Key, (object?)null) }
: Array.Empty<(CultRecordKey, object?)>();
}
return string.IsNullOrWhiteSpace(indexAttribute.Alias)
? member.Name
: indexAttribute.Alias;
if (TryGetDictionaryRefTypes(memberType, out var keyProperty, out var valueProperty))
{
return document =>
{
if (getValue(document) is not System.Collections.IEnumerable sequence)
return Array.Empty<(CultRecordKey, object?)>();
var results = new List<(CultRecordKey, object?)>();
foreach (var entry in sequence)
{
if (keyProperty.GetValue(entry) is ICultRecordRef reference)
results.Add((reference.Key, valueProperty.GetValue(entry)));
}
return results;
};
}
return document =>
{
if (getValue(document) is not System.Collections.IEnumerable sequence)
return Array.Empty<(CultRecordKey, object?)>();
var results = new List<(CultRecordKey, object?)>();
foreach (var entry in sequence)
{
if (entry is ICultRecordRef reference)
results.Add((reference.Key, (object?)null));
}
return results;
};
}
private static Type? ResolveReferenceTarget(Type memberType, Type? explicitTarget)

View file

@ -0,0 +1,274 @@
#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);
Assert.That(number, Is.EqualTo(3.5d));
}
[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);
Assert.That(number, Is.EqualTo(4.25d));
}
[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);
}
// 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"));
}
[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;
}
[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();
}
}
}