CultLib/tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs
Meta Vens 62ebba4ab5 R-AO: shared vector for two-in-target shared-key resolution
Adds citer-both-in-target to the shared fixture, the first row in file (and
row-order) to cite "dup-key" through Design - both leaf_a and leaf_b at that
key are in Design's declared target, unlike citer_narrow's narrow_ref above
it (only ever one in-target candidate). Adds the matching vector case to
both runtimes' case lists and regenerates both written vector files
(CULTNET_WRITE_VECTORS=1), so cross-runtime agreement on this shape is
pinned by the parity check, not just asserted per-runtime.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:18:11 +02:00

639 lines
37 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
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.
//
// Self's ruling, 2026-09-22 (shared-fixture ruling, on top of the Cut 1 fix batch): both this
// writer and packages/cultnet-rs/tests/selection.rs's write_selection_vectors_for_the_reference
// build their rows from the one shared
// contracts/cultnet/interop/selection-vectors.fixture.json, keyed by each schema's real
// SHA-256 content-hash id, exactly as CultDocumentRegistry computes it for the fixture's own
// document types below - never by schema *name* as a stand-in id, which let every vector that
// carried an id decode on the wrong runtime's terms (Soul's whole-cut pass, F3: a name-as-id
// fixture masked the real alias-matching gap between CultNetSchemaAliasMatching and
// cultnet_rs::selection::schema_alias). AssertSchemaIdMatches below fails loudly, not silently,
// if this file's reflected shape ever drifts from the committed fixture.
public sealed class SelectionParityVectorTests
{
private static CultDocumentRegistry Registry() => CultDocumentRegistry.ForTypes(new[]
{
typeof(ParityLeafA), typeof(ParityLeafB), typeof(ParityCiter), typeof(ParityCiterNarrow)
});
private static CultNetSelectionEvaluator.Row Row(CultDocumentRegistry registry, object document, string key, long ordinal) =>
new(registry.GetRequired(document.GetType()), new CultRecordKey(key), document, ordinal);
// ----------------------------------------------------------------------------------------
// The shared fixture: contracts/cultnet/interop/selection-vectors.fixture.json. Holds the
// real schema ids (and their unversioned aliases) and every row either runtime needs:
// astral/BMP-private-use keys (R-C), a float tie (R-D), a cites/cited pair, and a cites
// target reached by alias (R-E). Both writers build from it; neither hand-rolls its own row
// set any more.
// ----------------------------------------------------------------------------------------
// R-E: two alias forms, because the two runtimes' schema-alias matchers key off different
// attributes and no candidate resolves through both (see the fixture file's "//aliases"
// note and this file's report of the confirmed defect). NameAlias is what
// CultNetSchemaAliasMatching's descriptor overload (the only one any C# call site uses)
// can resolve; HashAlias is what cultnet_rs::selection::schema_alias can resolve.
private sealed record FixtureSchema(string SchemaId, string NameAlias, string HashAlias);
// R-AF: no TargetSchema - the wire a stored reference actually travels on carries a bare
// record key (GameCult.Caching.MessagePack.CultRecordRefFormatter<T> writes only
// value.Key.Value), so the fixture stopped pretending otherwise. See the fixture file's
// "//references" note.
private sealed record FixtureReference(string Role, string TargetKey);
private sealed record FixtureRow(
string Schema, string Key, long Ordinal,
IReadOnlyDictionary<string, string> Fields,
IReadOnlyList<FixtureReference> References);
private static string FixturePath() =>
Path.Combine(RepoRoot(), "contracts", "cultnet", "interop", "selection-vectors.fixture.json");
private static (IReadOnlyDictionary<string, FixtureSchema> Schemas, IReadOnlyList<FixtureRow> Rows) LoadFixture()
{
using var document = JsonDocument.Parse(File.ReadAllText(FixturePath()));
var root = document.RootElement;
var schemas = new Dictionary<string, FixtureSchema>(StringComparer.Ordinal);
foreach (var entry in root.GetProperty("schemas").EnumerateObject())
{
schemas[entry.Name] = new FixtureSchema(
entry.Value.GetProperty("schemaId").GetString()!,
entry.Value.GetProperty("nameAlias").GetString()!,
entry.Value.GetProperty("hashAlias").GetString()!);
}
var rows = new List<FixtureRow>();
foreach (var row in root.GetProperty("rows").EnumerateArray())
{
var fields = new Dictionary<string, string>(StringComparer.Ordinal);
if (row.TryGetProperty("fields", out var fieldsElement))
{
foreach (var field in fieldsElement.EnumerateObject())
fields[field.Name] = field.Value.GetString()!;
}
var references = new List<FixtureReference>();
if (row.TryGetProperty("references", out var referencesElement))
{
foreach (var reference in referencesElement.EnumerateArray())
{
references.Add(new FixtureReference(
reference.GetProperty("role").GetString()!,
reference.GetProperty("targetKey").GetString()!));
}
}
rows.Add(new FixtureRow(
row.GetProperty("schema").GetString()!,
row.GetProperty("key").GetString()!,
row.GetProperty("ordinal").GetInt64(),
fields,
references));
}
return (schemas, rows);
}
private static void AssertSchemaIdMatches(CultDocumentRegistry registry, Type type, string expected)
{
var actual = registry.GetRequired(type).SchemaId;
Assert.That(actual, Is.EqualTo(expected),
$"{type.Name}'s real schema id drifted from contracts/cultnet/interop/selection-vectors.fixture.json - " +
"regenerate the fixture (its schemas.*.schemaId/alias fields) from CultDocumentRegistry before trusting these vectors.");
}
private static object BuildDocument(FixtureRow row) => row.Schema switch
{
"leaf_a" => new ParityLeafA
{
Name = row.Key, Kind = row.Fields["kind"], Mass = float.Parse(row.Fields["mass"], CultureInfo.InvariantCulture)
},
"leaf_b" => new ParityLeafB
{
Name = row.Key, Kind = row.Fields["kind"], Mass = float.Parse(row.Fields["mass"], CultureInfo.InvariantCulture)
},
"citer" => new ParityCiter
{
Name = row.Key,
Design = new CultRecordRef<ParityMiddle>(new CultRecordKey(
row.References.Single(reference => reference.Role == "Design").TargetKey))
},
// R-AF: citer_narrow's reference is declared to ParityLeafA only (one leaf, not
// ParityMiddle) - the shared-key shape needs a role whose declared target excludes at
// least one of the schemas that can share a record key.
"citer_narrow" => new ParityCiterNarrow
{
Name = row.Key,
NarrowRef = new CultRecordRef<ParityLeafA>(new CultRecordKey(
row.References.Single(reference => reference.Role == "narrow_ref").TargetKey))
},
_ => throw new InvalidOperationException($"selection-vectors.fixture.json: unknown schema '{row.Schema}'")
};
// The one row set: both SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference
// (reading rs-written) and WriteSelectionVectors (writing cs-written) build from it, so both
// committed vector files are regenerated together whenever the fixture changes.
private static (CultDocumentRegistry registry, CultNetSelectionEvaluator.Row[] rows, IReadOnlyDictionary<string, FixtureSchema> schemas) BuildFixture()
{
var registry = Registry();
var (schemas, fixtureRows) = LoadFixture();
AssertSchemaIdMatches(registry, typeof(ParityLeafA), schemas["leaf_a"].SchemaId);
AssertSchemaIdMatches(registry, typeof(ParityLeafB), schemas["leaf_b"].SchemaId);
AssertSchemaIdMatches(registry, typeof(ParityCiter), schemas["citer"].SchemaId);
AssertSchemaIdMatches(registry, typeof(ParityCiterNarrow), schemas["citer_narrow"].SchemaId);
var rows = fixtureRows.Select(row => Row(registry, BuildDocument(row), row.Key, row.Ordinal)).ToArray();
return (registry, rows, schemas);
}
private static IEnumerable<(string Name, CultNetSelection Selection)> Cases(IReadOnlyDictionary<string, FixtureSchema> schemas)
{
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" } }
});
// R-E: the exact real schema id - matches on both runtimes' first (exact string)
// comparison branch, independent of the alias-matching gap below.
yield return ("incoming_exists_true", new CultNetSelection
{
Schemas = new[] { schemas["leaf_a"].SchemaId },
Cited = new CultNetIncoming { Role = "Design", Exists = true }
});
yield return ("incoming_negation", new CultNetSelection
{
Schemas = new[] { schemas["leaf_a"].SchemaId },
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" } } }
});
// R-D: the row's canonical rendering of 2233759.25f is its exact decimal expansion, not a
// shortest round-trip form that could round the tie the other way - ge against the exact
// value must match.
yield return ("float_tie_exact_ge", new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "2233759.25" } }
});
// R-E: the exact real schema id, mirroring cultnet-rs's hop_by_role_cites_exact_id.
// R-AF: Keys=["citer-1"] (the fixture's one row that actually cites a-eq via Design)
// scopes this away from citer_narrow's rows, as hygiene rather than necessity -
// landing R-AF's vectors surfaced a real R-T/R-W parity bug this exposed: Rust's
// matches_citation used to resolve and R-W-check every declared reference a row
// carries before filtering by the citation's own role, where C#'s
// ReferenceMembers(descriptor, citation.Role) filters by role first
// (CultNetSelectionEvaluator.cs:451-458). Fixed in cultnet_rs::selection::matches_citation
// to filter first too, so narrow-citer-refused's out-of-target narrow_ref edge no longer
// reaches a Role="Design" citation in either runtime - this vector keeps the Keys scope
// anyway so it never depends on that.
yield return ("hop_by_role_cites_exact_id", new CultNetSelection
{
Keys = new[] { "citer-1" },
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "a-eq" }, Role = "Design" }
});
// R-E: the same citation, but the target is named by its C#-resolvable alias
// ("leaf_a.v9") rather than the exact id. CultNetSchemaAliasMatching's descriptor overload
// (CultNetDatabase.cs:74-87, the only overload any `schemas`/`cites.target.schemaId` call
// site in src/ uses) strips the trailing ".v9" and compares "leaf_a" to
// ParityLeafA's descriptor.SchemaName - a real match here, in the reference. S-12
// (2026-09-22) is not a leftover defect: cultnet_rs::selection::schema_alias now resolves
// the same way, through Row::schema_name/RowSet::schema_name standing in for
// CultDocumentDescriptor.SchemaName (packages/cultnet-rs/src/selection.rs:452-475), so
// this vector's expectedIds are real and non-empty in both runtimes - the alias port made
// the two DIFFERENT alias forms deliberate (see the fixture file's "//aliases" note)
// rather than leaving this one unresolved on the Rust side.
// R-AF: Role="Design" plus Keys=["citer-1"] for the same reason as
// hop_by_role_cites_exact_id above.
yield return ("cites_target_by_name_alias", new CultNetSelection
{
Keys = new[] { "citer-1" },
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].NameAlias, RecordKey = "a-eq" }, Role = "Design" }
});
// R-E: `schemas` reached by the same name alias. Same one-sided-match caveat as above.
yield return ("schemas_by_name_alias", new CultNetSelection { Schemas = new[] { schemas["leaf_a"].NameAlias } });
// R-C: BmpPrivateUseKey (U+E000) sorts before AstralKey (U+1F602) in code-point order,
// despite AstralKey's leading UTF-16 code unit (0xD83D) being numerically smaller.
yield return ("astral_key_code_point_order", new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "unicode" } } }
});
// R-I: page bytes under each projection, over the same match set.
yield return ("header_projection", new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "unicode" } } },
Projection = CultNetSelectionProjections.Header
});
yield return ("document_projection", new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "unicode" } } },
Projection = CultNetSelectionProjections.Document
});
// R-AJ: a tied ordinal across two DIFFERENT schemas (kind=tie-schema is leaf_b/"zz-tie-
// schema" and leaf_a/"aa-tie-schema") - the shape the row order's schemaId tiebreak
// needs and the astral/BMP pair above never covered (both those rows are leaf_a). The
// fixture's keys are anti-correlated with the real schema ids on purpose, so a
// schemaId-first order and a recordKey-first order disagree: only the correct
// composition puts leaf_b's row ("zz-tie-schema") first.
yield return ("row_tiebreak_schema_id_before_record_key_at_tied_ordinal", new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "tie-schema" } } }
});
// R-AF: "dup-key" carries two rows (leaf_a and leaf_b); citer_narrow's narrow_ref
// targets leaf_a only. Keys scopes the citer candidate set to narrow-citer-accepted
// alone, so this vector never touches narrow-citer-refused's bad edge (R-W resolves
// every reference member unconditionally, so leaving both citer_narrow rows in one
// candidate set would always refuse regardless of which edge the query named). One
// rule, derived from the wire (docs/cultnet-selection-cut.md, R-AF): resolving "the
// row this edge names" is by record key plus the declared leaf set, not an exact
// (schema, key) pair, so this must accept in both runtimes and resolve to the leaf_a
// row even though a leaf_b row shares the same key.
yield return ("shared_key_narrow_ref_resolves_leaf_within_target", new CultNetSelection
{
Keys = new[] { "narrow-citer-accepted" },
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "dup-key" }, Role = "narrow_ref" }
});
// R-AO: "dup-key" has two in-target candidates for citer's Design role (unlike
// citer_narrow's narrow_ref above, which only ever had one). citer-both-in-target is the
// first row in the fixture file (and so in both runtimes' row order) to name it through
// Design - resolution must deterministically pick the first in-target candidate in that
// order, the leaf_a row (ordinal 11), never the leaf_b row (ordinal 12) sharing the key.
yield return ("shared_key_design_resolves_the_first_in_target_candidate_in_row_order", new CultNetSelection
{
Keys = new[] { "citer-both-in-target" },
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "dup-key" }, Role = "Design" }
});
}
// R-AF: the shared-key shape's other direction - the only row at the declared edge's key is
// outside the reference's declared target, so evaluating the selection itself must refuse
// (CultNetSelectionReferenceOutsideTargetException/SelectionRefusal::ReferenceOutsideTarget),
// not merely return an empty page. Keys scopes the candidate set to narrow-citer-refused
// alone (see the comment on shared_key_narrow_ref_resolves_leaf_within_target above).
private static IEnumerable<(string Name, CultNetSelection Selection)> EvaluationRefusalCases(
IReadOnlyDictionary<string, FixtureSchema> schemas)
{
yield return ("shared_key_narrow_ref_refuses_when_only_candidate_is_outside_target", new CultNetSelection
{
Keys = new[] { "narrow-citer-refused" },
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].SchemaId, RecordKey = "narrow-only" }, Role = "narrow_ref" }
});
}
// 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.
//
// refuses_unmatched_cites_target_schema and refuses_cites_target_by_hash_shaped_alias now
// land here too: the C# matrix Hands pass added the door check
// (CultNetSelection.cs:389-394) that CultNetSelection.Validate refuses a cites.target.schemaId
// that CultNetSchemaAliasMatching cannot resolve to any declared schema. Under Self's ruling
// 2026-09-22 ("the C# reference's alias rule is the rule"), C# only ever resolves a name-form
// alias, never a hash-shaped one, so a hash-shaped cites.target.schemaId is exactly such an
// unresolved target and is refused at the same field.
private static IEnumerable<(string Name, CultNetSelection Selection, string ExpectedField)> RefusalCases(
IReadOnlyDictionary<string, FixtureSchema> schemas)
{
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");
yield return ("refuses_unmatched_cites_target_schema", new CultNetSelection
{
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = "sha256:not-a-declared-schema", RecordKey = "a-eq" } }
}, "cites.target.schemaId");
yield return ("refuses_cites_target_by_hash_shaped_alias", new CultNetSelection
{
Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = schemas["leaf_a"].HashAlias, RecordKey = "a-eq" } }
}, "cites.target.schemaId");
}
// R-E: id, not name - a row's cross-runtime identity is its real schema id plus its key, the
// same shape cultnet-rs's row_id() produces.
private static string RowId(CultNetSelectionEvaluator.Row row) => $"{row.Descriptor.SchemaId}/{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");
// Self's ruling, 2026-09-22 (shared-fixture ruling): a real parity defect is reported in
// full, not truncated by whichever vector happened to come first in the file - every
// vector is judged and every mismatch collected before the test fails once, mirroring
// packages/cultnet-rs/tests/selection.rs's own reader.
var failures = new List<string>();
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)
{
try
{
selection.Validate(registry.AllDescriptors.ToArray());
failures.Add($"{name}: expected refusal at '{refusalField.GetString()}' but the reference accepted it");
}
catch (CultNetSelectionInvalidException error)
{
if (error.Field != refusalField.GetString())
failures.Add($"{name}: refusal field - expected '{refusalField.GetString()}', got '{error.Field}'");
}
continue;
}
// R-AF: a small, separate signal from expectedRefusalField above - that one is the
// door (Validate, never touches Select); this one names an evaluation-time typed
// refusal Select itself must throw (currently only reference_outside_target, S18).
// Deliberately narrower than R-Z/R-AH's page-bytes-and-code parity harness (not
// this cut's job): it checks which typed refusal fires, nothing about wire bytes.
if (vector.TryGetProperty("expectedEvaluationRefusal", out var evalRefusal) && evalRefusal.ValueKind != JsonValueKind.Null)
{
var expectedKind = evalRefusal.GetString();
try
{
CultNetSelectionEvaluator.Select(registry, rows, selection, asOf);
failures.Add($"{name}: expected Select to refuse ({expectedKind}) but it accepted the selection");
}
catch (CultNetSelectionReferenceOutsideTargetException) when (expectedKind == "reference_outside_target")
{
// expected
}
catch (Exception error)
{
failures.Add($"{name}: expected Select to refuse with {expectedKind}, got {error.GetType().Name}: {error.Message}");
}
continue;
}
CultNetSelectionEvaluator.Evaluation evaluation;
try
{
evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf);
}
catch (Exception error)
{
var expectedIds = vector.GetProperty("expectedIds").EnumerateArray().Select(e => e.GetString()).ToArray();
failures.Add($"{name}: expected real evaluation (expectedIds=[{string.Join(", ", expectedIds)}]) but Select threw {error.GetType().Name}: {error.Message}");
continue;
}
var expectedIdsList = vector.GetProperty("expectedIds").EnumerateArray().Select(e => e.GetString()).ToArray();
var actualIds = evaluation.Rows.Select(RowId).ToArray();
if (!actualIds.SequenceEqual(expectedIdsList))
failures.Add($"{name}: row ids - expected [{string.Join(", ", expectedIdsList)}], got [{string.Join(", ", actualIds)}]");
var expectedMatched = vector.GetProperty("matched").GetUInt32();
if ((uint)evaluation.TotalMatched != expectedMatched)
failures.Add($"{name}: matched - expected {expectedMatched}, got {evaluation.TotalMatched}");
var expectedHasNext = vector.GetProperty("hasNext").GetBoolean();
if ((evaluation.NextCursor != null) != expectedHasNext)
failures.Add($"{name}: hasNext - expected {expectedHasNext}, got {evaluation.NextCursor != null}");
var expectedEdges = vector.GetProperty("expectedEdges").EnumerateArray().ToArray();
if (evaluation.Edges.Count != expectedEdges.Length)
{
failures.Add($"{name}: edge count - expected {expectedEdges.Length}, got {evaluation.Edges.Count}");
continue;
}
for (var i = 0; i < expectedEdges.Length; i++)
{
var edge = evaluation.Edges[i];
var expectedEdge = expectedEdges[i];
var expectedPayload = expectedEdge.GetProperty("payloadBase64");
var actualPayload = edge.Payload == null ? null : Convert.ToBase64String((byte[])edge.Payload);
var payloadMatches = expectedPayload.ValueKind == JsonValueKind.Null
? actualPayload == null
: actualPayload == expectedPayload.GetString();
if (RowId(edge.From) != expectedEdge.GetProperty("fromId").GetString() ||
edge.Role != expectedEdge.GetProperty("role").GetString() ||
RowId(edge.To) != expectedEdge.GetProperty("toId").GetString() ||
!payloadMatches)
{
failures.Add($"{name}: edge[{i}] mismatch");
}
}
}
Assert.That(failures, Is.Empty,
$"{failures.Count} of {vectors.GetArrayLength()} vectors disagreed with the reference:\n" + string.Join("\n", failures));
}
// 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, schemas) = BuildFixture();
var vectors = new List<object>();
foreach (var (name, selection) in Cases(schemas))
{
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,
expectedEvaluationRefusal = (string?)null,
expectedIds = evaluation.Rows.Select(RowId).ToArray(),
matched = (uint)evaluation.TotalMatched,
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 cases 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(schemas))
{
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,
expectedEvaluationRefusal = (string?)null,
expectedIds = Array.Empty<string>(),
matched = 0U,
hasNext = false,
expectedEdges = Array.Empty<object>()
});
}
// R-AF: evaluation-time refusal vectors. Never handed to Validate - the selection
// decodes and passes the door; only Select itself refuses, and only once the shared-key
// resolution rule actually runs. Asserted here before being committed, mirroring how
// RefusalCases above asserts Validate refuses before its vectors are written.
foreach (var (name, selection) in EvaluationRefusalCases(schemas))
{
Assert.Throws<CultNetSelectionReferenceOutsideTargetException>(
() => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1),
$"{name} must be refused by Select before it can be committed as an evaluation-refusal vector");
var selectionBytes = MessagePackSerializer.Serialize(selection, CultNetSchemaMessageSerialization.Options);
vectors.Add(new
{
name,
selectionMessagePackBase64 = Convert.ToBase64String(selectionBytes),
asOf = 1UL,
expectedRefusalField = (string?)null,
expectedEvaluationRefusal = (string?)"reference_outside_target",
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);
}
// Dumps each fixture schema's real, reference-computed id (and lets a maintainer derive the
// unversioned alias by stripping the trailing ".v1") so
// contracts/cultnet/interop/selection-vectors.fixture.json can be regenerated after a shape
// change. Not part of the parity contract itself.
[Test]
public void DumpFixtureSchemaIds()
{
if (Environment.GetEnvironmentVariable("CULTNET_DUMP_SCHEMA_IDS") != "1")
Assert.Ignore("Set CULTNET_DUMP_SCHEMA_IDS=1 to print the fixture's real schema ids.");
var registry = Registry();
Console.WriteLine("SCHEMA_ID leaf_a=" + registry.GetRequired(typeof(ParityLeafA)).SchemaId);
Console.WriteLine("SCHEMA_ID leaf_b=" + registry.GetRequired(typeof(ParityLeafB)).SchemaId);
Console.WriteLine("SCHEMA_ID citer=" + registry.GetRequired(typeof(ParityCiter)).SchemaId);
}
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;
}
// R-AF: a citer whose declared reference target is one leaf only, so a record key shared
// by a ParityLeafA row and a ParityLeafB row has exactly one row inside the declared
// target and exactly one outside it - see the fixture file's "citer_narrow" schema.
[CultDocument("citer_narrow", "cultnet.selection-parity.citer_narrow.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class ParityCiterNarrow
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultIndex("narrow_ref")]
[CultReference(typeof(ParityLeafA))]
public CultRecordRef<ParityLeafA> NarrowRef;
}
}
}