docs/cultnet-selection-cut.md section 6/7: packages/cultnet-rs/src/selection.rs (new) - the Rust spelling of the vocabulary at wire parity with the corrected C# reference (e5348d3), matching D7: no cultcache-rs knowledge, the row owner supplies declared values and edges through two traits. - Wire types: Selection, FieldPredicate, Citation, Incoming, RecordRef, Edge, RawDocumentHeader, SelectionDocumentRecord (the v1 full-shape record, distinct from v0's CultNetRawDocumentRecord, which lacks schemaName/schemaVersion/ schemaContentHash - a pre-existing v0 gap this cut does not touch). SelectionOperator mirrors CultNetSelectionOperator for evaluator ergonomics; FieldPredicate.op stays a raw wire string, never a serde-tagged union. - canonical_number module (Q-J, section 2 "Numbers"): is_canonical (a hand-rolled digit-grammar parser, no regex dependency added), canonicalize_decimal_digits, expand_scientific_notation, compare (sign, then integer length, then digits, then padded fraction - no f64 anywhere), and render_i64/render_u64/render_f64/render_f32 for a Row implementer's own rendering. probe_f64_display_never_uses_exponent_notation empirically verifies the map's claim about Rust's f64 Display (confirmed true) rather than trusting it. - Row/RowSet traits (D7): schema_id, record_key, ordinal, values, number (canonical string, never f64), references for Row; all_schema_ids, declared_indexes, declared_roles, is_numeric, target_leaves for RowSet. A blanket `impl<T: Row> Row for &T` lets sorted/filtered slices of references satisfy generic bounds without an extra copy. - validate (S1 door refusals), matches (D6 single-row fast path, hop-bearing selections assert via debug_assert since Rust has no exception type to mirror C#'s InvalidOperationException), select (order/hop/cursor/paging, D9's reference_outside_target refusal, S23's per-row match - never through a unique-index map, because there is none here to begin with). - Cursor: mint/parse/digest is this runtime's own internal algorithm (a cursor is opaque and never crosses runtimes - each server answers its own pages), not a byte-for-byte port of the reference's; what must match is the *rule* (asOf + last position + a digest bound to the selection), which it does, pinned by RS-CursorStale-Revert/Loosening and RS-CursorDigest-Revert. - contracts.rs: three v1 CultNetMessage variants (SnapshotRequestV1, DatabaseSubscribeV1, SnapshotResponseRawV1) matching the C# messages' field-for-field shape. SnapshotResponseRawV1 carries binary payloads (documents[].payload, edges[].payload), so it is routed through the existing raw JSON-bypass path (parse_raw_cultnet_schema_message/ encode_raw_cultnet_schema_message) alongside v0's DocumentPutRaw/ SnapshotResponseRaw, with its own require_*/encode_* helpers for SelectionDocumentRecord, RawDocumentHeader, Edge and RecordRef. SnapshotRequestV1/DatabaseSubscribeV1 carry no binary and use the ordinary serde_json-intermediate path. No message-layer selection semantics validation here, matching the reference (CultNetSchemaMessageSerialization.cs only deserializes; a server calls selection::validate against its own RowSet before evaluating). - snapshot_query.rs (D3/D4): serve_read_only_raw_snapshot's two hand-rolled allowlist filters collapse into one call to selection::matches over a lowered v0 Selection, through a minimal RawSnapshotRow wrapper whose ordinal/values/number/references are unreachable (the lowered selection never sets fields/cites/cited, so matches_schema_keys_fields never calls them) - documented, not hidden. Ruling (Self, 2026-09-22, docs/cultnet-selection-cut.md commit 2 fix batch, mirrored here per the coordinator's mid-task instruction rather than C#'s pre-fix spelling as it stood ate5348d3): - validate refuses an empty-but-present schemas/keys list (already had this) and now also refuses any individual entry that is empty or whitespace (fields[i]-style names: schemas[i]/keys[i]). - The evaluator never sees []; matches_schema_keys_fields does not special-case an empty list as "no filter" (it never did here - that C# fallback, CultNetSelectionEvaluator.cs:84 pre-fix, was never mirrored into Rust). - snapshot_query.rs's v0 lowering gets its own cleaning, owned by the lowering and not the evaluator: lower_v0_list turns an empty or all-blank v0 schemaIds/recordKeys list into None before it ever reaches validate or select. Tests: cultnet-rs 173 passed, 0 failed (was 111 baseline #[test]s pre-cut; +62 across lib unit tests, tests/selection.rs and tests/cultnet.rs). cultcache-rs unmodified, 64 passed, 0 failed (untouched by this commit - confirmed unaffected). GameCult.Networking.Tests (the new SelectionParityVectorTests.cs file, compiled but not yet fully exercised - see below) builds clean. Mutation harness (tools/eureka-mutations.ps1, scripts/mutate-cultnet- selection-rust.entries.ps1): 22 entries against src/selection.rs and src/snapshot_query.rs, all KILLED, M0 green. Covers: S1's empty-list and new blank-entry refusals (schemas and keys, revert x4), order/descending (revert), cursor staleness (revert + loosening) and digest (revert), S7's incoming negation (revert + loosening), S16's le/ge boundaries (revert x2), S18's reference_outside_target (revert), Q-J's door regex and -0 exclusion (revert x2), Q-J's comparator integer-length and sign (revert x2), Q-J's integer-through-float64 renderer, decimal trailing-zero stripping and exponent expansion (revert x3, the last two pinned via direct canonical_number unit tests since render_f64 itself cannot reach exponent notation given Rust's Display contract - verified, not assumed), and the new v0-lowering rule (revert). Negative grep: `rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src` empty. Not included here, landing as commit 4 once ready: the committed parity vector files (contracts/cultnet/interop/selection-vectors.{cs,rs}-written.json). Both directions already work for six evaluated selections and the [] door-refusal case; the blank-key-entry refusal vector cannot be generated from the C# side yet because the parallel C# fix batch implementing that door check (docs/cultnet-selection-cut.md, "Self's rulings...for the commit 2 fix batch") has not landed on this branch as of this commit. Rust's own tests (validation_refuses_a_blank_entry_in_schemas_or_keys, serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter) already pin both new rules independently of C#. Structural delta: packages/cultnet-rs/src/selection.rs +1407 (new), contracts.rs +616/-21, snapshot_query.rs +76/-21 (net +55, collapsing the fifth selector engine into one call), lib.rs +2, tests/cultnet.rs +67 (new v0-lowering test), tests/selection.rs +900 (new, evaluator tests + vector machinery), scripts/mutate-cultnet-selection-rust.entries.ps1 +233 (new), tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs +296 (new). Against section 14's ~430-line estimate for selection.rs+contracts.rs: the two together land at ~2000 lines added, well over estimate - the map's own §14 correction already flagged commits 0/1 running roughly 2x estimate for the same reason (the hop's incoming index, the cursor, and per-field door messages cost more than the prose assumed), and the v1 raw-message handling (SelectionDocumentRecord/RawDocumentHeader/Edge require_*/encode_* helpers, ~400 lines) was not separately budgeted at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
273 lines
15 KiB
C#
273 lines
15 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using GameCult.Caching;
|
|
using MessagePack;
|
|
using NUnit.Framework;
|
|
|
|
namespace GameCult.Networking.Tests
|
|
{
|
|
// CultNet typed selection, Cut 1 commit 3/4 (docs/cultnet-selection-cut.md section 6/10, S12):
|
|
// the C#<->Rust parity vectors. New file; no existing C# source is modified.
|
|
//
|
|
// "schemaId" inside the committed vector files is each fixture document's *SchemaName*
|
|
// ("leaf_a", "leaf_b", "citer"), never CultDocumentDescriptor.SchemaId - a SHA-256 content
|
|
// hash over the type's canonical shape that Rust has no way to reproduce independently, and
|
|
// that the reference itself does not run through alias matching everywhere (MatchesCitation
|
|
// compares Citation.Target.SchemaId against the real hash exactly, with no SchemaName
|
|
// fallback). The vectors below therefore only exercise `cited` (role/existence, matched by
|
|
// record key alone - no schema id comparison at all) for the hop, never `cites` (which would
|
|
// need a real cross-runtime-agreed schema id this fixture pair cannot produce). This is a
|
|
// fixture-identity convention local to these vectors, not a claim about the real wire.
|
|
public sealed class SelectionParityVectorTests
|
|
{
|
|
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[]
|
|
{
|
|
typeof(ParityLeafA), typeof(ParityLeafB), typeof(ParityCiter)
|
|
});
|
|
|
|
private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) =>
|
|
new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal);
|
|
|
|
private static (CultDocumentRegistry registry, CultNetSelectionEvaluator.Row[] rows) BuildFixture()
|
|
{
|
|
var registry = Registry();
|
|
var rows = new[]
|
|
{
|
|
Row(registry, new ParityLeafA { Name = "a-lo", Kind = "weapon", Mass = 4 }, "a-lo", 1),
|
|
Row(registry, new ParityLeafA { Name = "a-eq", Kind = "weapon", Mass = 5 }, "a-eq", 2),
|
|
Row(registry, new ParityLeafB { Name = "b-hi", Kind = "armor", Mass = 6 }, "b-hi", 3),
|
|
Row(registry, new ParityLeafA { Name = "shared-1", Kind = "shared", Mass = 1 }, "shared-1", 4),
|
|
Row(registry, new ParityLeafB { Name = "shared-2", Kind = "shared", Mass = 1 }, "shared-2", 5),
|
|
Row(registry, new ParityLeafA { Name = "shared-3", Kind = "shared", Mass = 1 }, "shared-3", 6),
|
|
Row(registry, new ParityCiter { Name = "citer-1", Design = new CultRecordRef<ParityMiddle>(new CultRecordKey("a-eq")) }, "citer-1", 7)
|
|
};
|
|
return (registry, rows);
|
|
}
|
|
|
|
// The same six selections packages/cultnet-rs/tests/selection.rs's
|
|
// write_selection_vectors_for_the_reference builds against its own fixture rows (which
|
|
// carry the same schema names, record keys, ordinals and declared values as BuildFixture
|
|
// above, independently constructed - a within-runtime round trip pins nothing, section 10).
|
|
private static IEnumerable<(string Name, CultNetSelection Selection)> Cases()
|
|
{
|
|
yield return ("any_of_plus_ge_conjunction", new CultNetSelection
|
|
{
|
|
Fields = new[]
|
|
{
|
|
new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } },
|
|
new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "5" }
|
|
}
|
|
});
|
|
yield return ("descending_with_limit", new CultNetSelection { Descending = true, Limit = 3 });
|
|
yield return ("gt_numeric_boundary", new CultNetSelection
|
|
{
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "gt", Number = "1" } }
|
|
});
|
|
yield return ("incoming_exists_true", new CultNetSelection
|
|
{
|
|
Schemas = new[] { "leaf_a" },
|
|
Cited = new CultNetIncoming { Role = "Design", Exists = true }
|
|
});
|
|
yield return ("incoming_negation", new CultNetSelection
|
|
{
|
|
Schemas = new[] { "leaf_a" },
|
|
Cited = new CultNetIncoming { Role = "Design", Exists = false }
|
|
});
|
|
yield return ("shared_index_value_three_rows", new CultNetSelection
|
|
{
|
|
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "shared" } } }
|
|
});
|
|
}
|
|
|
|
// Self's ruling, 2026-09-22 (commit 2 fix batch): door-refusal vectors. Never evaluated on
|
|
// either side - only decoded and handed to Validate/validate, which must refuse at exactly
|
|
// the named field. Mirrors packages/cultnet-rs/tests/selection.rs's refusal_cases.
|
|
private static IEnumerable<(string Name, CultNetSelection Selection, string ExpectedField)> RefusalCases()
|
|
{
|
|
yield return ("refuses_empty_schemas_list", new CultNetSelection { Schemas = Array.Empty<string>() }, "schemas");
|
|
yield return ("refuses_blank_key_entry", new CultNetSelection { Keys = new[] { "a-eq", " " } }, "keys[1]");
|
|
}
|
|
|
|
private static string RowId(CultNetSelectionEvaluator.Row row) => $"{row.Descriptor.SchemaName}/{row.Key.Value}";
|
|
|
|
// Vectors written by Rust (packages/cultnet-rs/tests/selection.rs,
|
|
// write_selection_vectors_for_the_reference) decode and evaluate identically here.
|
|
[Test]
|
|
public void SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference()
|
|
{
|
|
var path = Path.Combine(RepoRoot(), "contracts", "cultnet", "interop", "selection-vectors.rs-written.json");
|
|
if (!File.Exists(path))
|
|
Assert.Inconclusive($"{path} is missing - run cargo test -p cultnet-rs --test selection with CULTNET_WRITE_VECTORS=1 first.");
|
|
|
|
var (registry, rows) = BuildFixture();
|
|
using var document = JsonDocument.Parse(File.ReadAllText(path));
|
|
var vectors = document.RootElement.GetProperty("vectors");
|
|
Assert.That(vectors.GetArrayLength(), Is.GreaterThan(0), "the vector file must carry at least one vector");
|
|
|
|
foreach (var vector in vectors.EnumerateArray())
|
|
{
|
|
var name = vector.GetProperty("name").GetString();
|
|
var selectionBytes = Convert.FromBase64String(vector.GetProperty("selectionMessagePackBase64").GetString()!);
|
|
var selection = MessagePackSerializer.Deserialize<CultNetSelection>(selectionBytes, CultNetSchemaMessageSerialization.Options);
|
|
var asOf = vector.GetProperty("asOf").GetUInt64();
|
|
|
|
if (vector.TryGetProperty("expectedRefusalField", out var refusalField) && refusalField.ValueKind != JsonValueKind.Null)
|
|
{
|
|
var error = Assert.Throws<CultNetSelectionInvalidException>(() => selection.Validate(registry.AllDescriptors.ToArray()), $"vector {name} must be refused");
|
|
Assert.That(error!.Field, Is.EqualTo(refusalField.GetString()), $"vector {name}: refusal field");
|
|
continue;
|
|
}
|
|
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf);
|
|
|
|
var expectedIds = vector.GetProperty("expectedIds").EnumerateArray().Select(e => e.GetString()).ToArray();
|
|
Assert.That(evaluation.Rows.Select(RowId).ToArray(), Is.EqualTo(expectedIds), $"vector {name}: row ids");
|
|
Assert.That((uint)evaluation.Rows.Count, Is.EqualTo(vector.GetProperty("matched").GetUInt32()), $"vector {name}: matched");
|
|
Assert.That(evaluation.NextCursor != null, Is.EqualTo(vector.GetProperty("hasNext").GetBoolean()), $"vector {name}: hasNext");
|
|
|
|
var expectedEdges = vector.GetProperty("expectedEdges").EnumerateArray().ToArray();
|
|
Assert.That(evaluation.Edges.Count, Is.EqualTo(expectedEdges.Length), $"vector {name}: edge count");
|
|
for (var i = 0; i < expectedEdges.Length; i++)
|
|
{
|
|
var edge = evaluation.Edges[i];
|
|
var expectedEdge = expectedEdges[i];
|
|
Assert.That(RowId(edge.From), Is.EqualTo(expectedEdge.GetProperty("fromId").GetString()), $"vector {name}: edge[{i}].from");
|
|
Assert.That(edge.Role, Is.EqualTo(expectedEdge.GetProperty("role").GetString()), $"vector {name}: edge[{i}].role");
|
|
Assert.That(RowId(edge.To), Is.EqualTo(expectedEdge.GetProperty("toId").GetString()), $"vector {name}: edge[{i}].to");
|
|
var expectedPayload = expectedEdge.GetProperty("payloadBase64");
|
|
if (expectedPayload.ValueKind == JsonValueKind.Null)
|
|
Assert.That(edge.Payload, Is.Null, $"vector {name}: edge[{i}].payload");
|
|
else
|
|
Assert.That(Convert.ToBase64String((byte[])edge.Payload!), Is.EqualTo(expectedPayload.GetString()), $"vector {name}: edge[{i}].payload");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Writes contracts/cultnet/interop/selection-vectors.cs-written.json for Rust's
|
|
// selection_vectors_written_by_the_reference_decode_and_evaluate_identically_in_rust to
|
|
// judge. Gated the same way the QUIC campaign's C#-written vectors are
|
|
// (docs/typescript-quic-realtime-cut.md:15-21): committed, not regenerated on every run.
|
|
[Test]
|
|
public void WriteSelectionVectors()
|
|
{
|
|
if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1")
|
|
Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file.");
|
|
|
|
var (registry, rows) = BuildFixture();
|
|
var vectors = new List<object>();
|
|
foreach (var (name, selection) in Cases())
|
|
{
|
|
var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1);
|
|
var selectionBytes = MessagePackSerializer.Serialize(selection, CultNetSchemaMessageSerialization.Options);
|
|
|
|
vectors.Add(new
|
|
{
|
|
name,
|
|
selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes),
|
|
asOf = 1UL,
|
|
expectedRefusalField = (string?)null,
|
|
expectedIds = evaluation.Rows.Select(RowId).ToArray(),
|
|
matched = (uint)evaluation.Rows.Count,
|
|
hasNext = evaluation.NextCursor != null,
|
|
expectedEdges = evaluation.Edges.Select(edge => new
|
|
{
|
|
fromId = RowId(edge.From),
|
|
role = edge.Role,
|
|
toId = RowId(edge.To),
|
|
// None of this file's six vectors hop through a many-dictionary reference
|
|
// (the only shape D11 gives a non-null payload), so this is always null
|
|
// here; packages/cultnet-rs/tests/selection.rs's
|
|
// dictionary_reference_entries_keep_distinct_payload_bytes pins the
|
|
// payload-carrying case directly against Row::references() instead.
|
|
payloadBase64 = edge.Payload == null ? null : Convert.ToBase64String((byte[])edge.Payload)
|
|
}).ToArray()
|
|
});
|
|
}
|
|
|
|
// Self's ruling, 2026-09-22 (commit 2 fix batch): door-refusal vectors. Never
|
|
// evaluated on either side - only decoded and handed to Validate, which must refuse
|
|
// at exactly the named field.
|
|
foreach (var (name, selection, expectedField) in RefusalCases())
|
|
{
|
|
var error = Assert.Throws<CultNetSelectionInvalidException>(
|
|
() => selection.Validate(registry.AllDescriptors.ToArray()),
|
|
$"{name} must be refused by the reference before it can be committed as a refusal vector");
|
|
Assert.That(error!.Field, Is.EqualTo(expectedField), $"{name}: refusal field");
|
|
|
|
var selectionBytes = MessagePackSerializer.Serialize(selection, CultNetSchemaMessageSerialization.Options);
|
|
vectors.Add(new
|
|
{
|
|
name,
|
|
selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes),
|
|
asOf = 1UL,
|
|
expectedRefusalField = (string?)expectedField,
|
|
expectedIds = Array.Empty<string>(),
|
|
matched = 0U,
|
|
hasNext = false,
|
|
expectedEdges = Array.Empty<object>()
|
|
});
|
|
}
|
|
|
|
var directory = Path.Combine(RepoRoot(), "contracts", "cultnet", "interop");
|
|
Directory.CreateDirectory(directory);
|
|
var json = JsonSerializer.Serialize(new { vectors }, new JsonSerializerOptions { WriteIndented = true });
|
|
File.WriteAllText(Path.Combine(directory, "selection-vectors.cs-written.json"), json);
|
|
}
|
|
|
|
private static string RepoRoot()
|
|
{
|
|
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
|
|
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
|
|
directory = directory.Parent;
|
|
return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory.");
|
|
}
|
|
|
|
// A conceptual abstract middle (no [CultDocument]), matching Design's declared target so
|
|
// D9's leaf-set resolution reaches both ParityLeafA and ParityLeafB. SchemaName is the
|
|
// literal fixture-identity string ("leaf_a", "leaf_b", "citer") the vectors compare by -
|
|
// see this file's header comment.
|
|
public abstract class ParityMiddle
|
|
{
|
|
[Key(0)]
|
|
[CultName]
|
|
public string Name = string.Empty;
|
|
|
|
[Key(1)]
|
|
[CultIndex("kind")]
|
|
public string Kind = string.Empty;
|
|
|
|
[Key(2)]
|
|
[CultIndex("mass")]
|
|
public float Mass;
|
|
}
|
|
|
|
[CultDocument("leaf_a", "cultnet.selection-parity.leaf_a.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
public sealed class ParityLeafA : ParityMiddle
|
|
{
|
|
}
|
|
|
|
[CultDocument("leaf_b", "cultnet.selection-parity.leaf_b.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
public sealed class ParityLeafB : ParityMiddle
|
|
{
|
|
}
|
|
|
|
[CultDocument("citer", "cultnet.selection-parity.citer.v1")]
|
|
[MessagePackObject(AllowPrivate = true)]
|
|
public sealed class ParityCiter
|
|
{
|
|
[Key(0)]
|
|
[CultName]
|
|
public string Name = string.Empty;
|
|
|
|
[Key(1)]
|
|
[CultReference(typeof(ParityMiddle))]
|
|
public CultRecordRef<ParityMiddle> Design;
|
|
}
|
|
}
|
|
}
|