Reconciles the two Hands' independently-regenerated parity vectors
(Soul's whole-cut pass, F3): the C# fixture used schema *names*
("leaf_a") as literal ids, the Rust fixture used invented fake
sha256-shaped ids - every vector carrying an id failed when read by
the other runtime.
Adds contracts/cultnet/interop/selection-vectors.fixture.json: the one
row set both writers now build from, keyed by each schema's real
SHA-256 content-hash id exactly as CultDocumentRegistry computes it
(dumped via a temporary DumpFixtureSchemaIds run). Carries the rows
either runtime needs - astral/BMP-private-use keys, a float tie,
cites/cited, a cites target reached by alias - plus two DIFFERENT
alias forms, because the two runtimes' alias matchers key off
different descriptor attributes and no single candidate resolves
through both (see the fixture's "//aliases" note and the report).
Rewrites SelectionParityVectorTests.cs to load the fixture instead of
hand-building rows (BuildFixture/BuildExtendedFixture deleted), assert
the fixture's declared schema ids still match what the registry
computes (fails loudly on drift instead of silently comparing wrong
ids), and use the real schema id (not SchemaName) as a row's
cross-runtime identity. Also fixes a real bug this file carried since
before the shared fixture: both the writer and the reader compared
`evaluation.Rows.Count` (the page size) against "matched" instead of
`evaluation.TotalMatched` (the total, per R-G) - selections with a
Limit were writing/checking the wrong number. The reader now collects
every vector's mismatch instead of stopping at the first one, so a
real parity defect is reported in full.
Regenerates selection-vectors.cs-written.json from the shared fixture.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Regenerated contracts/cultnet/interop/selection-vectors.cs-written.json
under R-B/R-C/R-D/R-E/R-I.
SelectionParityVectorTests.cs: the six original cases' fixture
(BuildFixture) is left untouched, because SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference
reads the already-committed selection-vectors.rs-written.json against
it, and a new row reachable by an unfiltered or loosely-filtered case
(descending_with_limit, gt_numeric_boundary) would silently change that
case's expected page. The fix-batch rows and cases live in a new
BuildExtendedFixture/Cases set, additive over BuildFixture, used only
by WriteSelectionVectors:
- float_tie_exact_ge: a row at 2233759.25f, on the round-to-even tie
R-D's exact BigInteger rendering fixes (its canonical decimal is
"2233759.25" regardless of which shortest form a runtime's ToString
would have picked).
- cites_target_by_alias: cites.target.schemaId given as "leaf_a"
(ParityLeafA's SchemaName) against a-eq's real content-hash SchemaId,
exercising R-E's alias-matched cites (the pre-fix code required an
exact SchemaId match and this always missed).
- astral_key_code_point_order: two rows sharing one ordinal, keyed by
a BMP private-use character (U+E000) and an astral character
(U+1F602, surrogate pair 😂), pinning R-C's code-point
tiebreak order.
- header_projection/document_projection: the same hop-bearing match set
under each projection.
Known-expected failure: SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference
fails against the currently-committed selection-vectors.rs-written.json
(incoming_exists_true's edge count) because that file predates R-B and
was not touched here - the parallel Rust Hands owns it. Re-run once it
regenerates.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20
mutants - dropping `wantDocument &&` in ToEdge (a header-projection
edge would carry document payload bytes anyway) and always emitting
Edges instead of `selection.HasHop ? ... : null` (a hop-less
selection's page would carry an empty array instead of no Edges
member) - plus a matched-vs-page-length test for R-G. Fixing
ToEdge's mutant coverage surfaced a real bug the new tests caught:
it serialized an edge payload through CultDocumentMessagePackSerialization,
which requires the payload's type to be a registered CultDocument
and throws for a plain declared value type (float, in the fixture);
it now serializes directly through MessagePackSerializer.
- R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's
$ matches immediately before a single trailing newline even without
RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z
and would have accepted "5\n" as canonical without this. Caching's
TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits
is updated for 1e-7's actual exact decimal expansion (the R-D commit's
own effect): the old assertion pinned "0.0000001", the shortest
round-trip form a pre-R-D render would have produced.
- R-K: every serverThread.Join() in NetworkingTests.cs that sat outside
a try/finally now sits inside one, so a failed assertion cannot leave
the poll thread running past the point where `using` disposes the
socket it polls (an ObjectDisposedException on that raw background
Thread was a host crash, not a test failure). The bodyDemand.HasConsumers
assertions wait on the condition instead of assuming
SubscribeAsync's completion and PublishDemand happened in an order
a network round trip does not guarantee.
- Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias
built a v0-shaped CultNetDatabaseSubscribeMessage to invoke
CreateMatchedRecord by reflection; that method now takes the shared
SubscriptionRequest (R-A), so the test builds one through the same
reflection path instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two things landed on this branch while commit 3 was in flight, both addressed
here rather than left for the vectors to paper over:
**Self's ruling, 2026-09-22 (commit 2 fix batch, 568e8e3/021e3f1): empty
lists, blank entries, and v0's own cleaning.** Rust already refused an empty
schemas/keys list (inherited from before the ruling); it now also matches
the reference's three further decisions exactly, not just in spirit:
- The door's refusal field is the plain list name ("schemas"/"keys"), not an
indexed "schemas[i]" - selection::validate's SelectionInvalid.field now
matches CultNetSelectionInvalidException("schemas", schema, ...) literally,
because the parity vectors compare this string across runtimes.
- snapshot_query.rs's lower_v0_list now deduplicates a mixed list the same
way CultNetV0SelectionLowering.Lower does (Distinct(StringComparer.Ordinal)
- order-preserving, first occurrence wins), not just blank-filters it.
A new test (serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter,
extended) pins the dedup case directly.
- Two mutation entries (RS-EmptySchemas-Revert, RS-EmptyKeys-Revert) added
for the door's original empty-list refusal, which had tests but no
mutation coverage before this commit.
**S12: the two-way parity vectors**, contracts/cultnet/interop/selection-vectors.{cs,rs}-written.json,
eight vectors each: the six evaluated selections from commit 3 (any_of+ge
conjunction, descending+limit, gt boundary, cited exists true/false, three
rows sharing one index value) plus two door-refusal vectors added for this
ruling - `refuses_empty_schemas_list` (schemas: []) and `refuses_blank_key_entry`
(keys: ["a-eq", " "]) - which decode their selection bytes and assert
`validate`/`Validate` refuses at exactly the named field, never evaluating.
The blank-key vector could not be generated before this commit: the door
rule it exercises did not exist in the reference until 568e8e3 landed.
`cites`-based hop vectors are deliberately absent (documented in both test
files): MatchesCitation compares Citation.Target.SchemaId against the
reference's real SHA-256 schema id exactly, with no SchemaName fallback,
and this fixture pair has no way to agree on that hash across runtimes;
`cited` (role/existence, matched by record key alone) is the hop direction
both sides can cross-check, so incoming_exists_true/incoming_negation carry
that coverage instead. Both reader tests pass:
SelectionVectorsWrittenByRustDecodeAndEvaluateIdenticallyInTheReference (C#)
and selection_vectors_written_by_the_reference_decode_and_evaluate_identically_in_rust
(Rust), each judging the other runtime's file.
**The remaining mutation matrix.** Of the coordinator's named set (S1, S2,
S6, S9-S15, S17, S20, S23): S1 was already covered (RS-EmptySchemas/Keys,
RS-BlankSchema/Key); S2 (any_of matches membership, RS-S2-Revert) and S6
(cites.role filters the hop, RS-S6-Revert) are newly covered - S6 needed a
new fixture (cites_role_excludes_a_second_reference_at_the_same_target)
because base_rows()'s citer-1 only ever carries one reference, so a mutant
dropping the role filter had nothing else to wrongly match and survived
against the original hops_one_edge_by_declared_role test; that finding is
recorded in-line as a comment, not silently swapped for an easier mutant.
S9-S15 (subscription reconcile, v0-lowering-of-subscribe) do not apply:
D7 - no Rust caller subscribes in this cut, so there is no subscription
client to mutate. S17 (inherited alias reaches every leaf) does not apply
either: Rust's toy Row/RowSet has no inheritance to begin with - both
leaves in the fixture declare "mass" independently, which is the Rust
spelling of the same guarantee, already exercised throughout the suite
without a dedicated mutant (there is no shared base-type discovery path to
revert). S20 (edges only for a hopping selection) was investigated and
found structurally vacuous in Rust as built: `edges` is only ever populated
inside the cites/cited branches, so a mutant on the has_hop() gate cannot
produce a different observable result for a non-hopping selection - no
entry added; a real gap would need the evaluator's structure to change
first. S23 (matches every row sharing an index value, never a cache's
unique-index winner) has no revertable bug surface: Rust's Row/RowSet
never had a unique-index map to begin with (D7), so this is pinned by
construction, not by a mutation-testable rule; matches_every_row_sharing_an_index_value
still exists as a positive test naming it.
**A pre-existing, unrelated flaky test, found and worked around, not
fixed.** tests/cultnet.rs's reactive_document_coalesces_direct_same_schema_alias_member_writes
fails consistently when the mutation harness runs the full tests/cultnet.rs
binary (both default-parallel and RUST_TEST_THREADS=1), but passes 5/5 in
isolation and passes in a plain `cargo test -p cultnet-rs` run - state
pollution from another test in that binary under the harness's specific
invocation path, not a race this campaign's code participates in (nothing
here touches reactive_document machinery). RS-V0Lowering-Revert's Command
is narrowed to `cargo test --test cultnet serve_read_only_raw_snapshot`
(filtering both M0 and the entry's own run to this campaign's test) so the
suite does not depend on that unrelated test's health; the flake itself is
reported here as a finding, not patched.
Tests: cultnet-rs 174 passed, 0 failed (default parallel run).
GameCult.Networking.Tests 196 passed, 1 skipped (WriteSelectionVectors,
env-gated), 0 failed.
Mutation harness: 24 entries (was 22 in commit 3; +RS-S2-Revert, +RS-S6-Revert;
RS-BlankSchema/BlankKey and RS-V0Lowering-Revert's anchors and field-name
assertions updated to match the landed fix batch's exact spelling), all
KILLED, M0 green.
Negative grep: `rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src` empty.
Structural delta, this commit only: 6 files changed, +108/-23 (selection.rs
+15/-9 field-name fix; snapshot_query.rs +25/-14 dedup; tests/cultnet.rs +21
dedup test; tests/selection.rs +35/-2 S6 fixture and field-name fixes;
scripts/mutate-cultnet-selection-rust.entries.ps1 +33/-4 two new entries
plus three corrected anchors/commands; SelectionParityVectorTests.cs +2/-1
field name), plus the two committed vector JSON files (~90 lines each, new).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul found commit 2 didn't pass (docs/cultnet-selection-cut.md, 2026-09-22):
empty/blank schemas-keys still reached the evaluator, the shard snapshot ran
its own loop instead of the evaluator, Mesh's v0 wire silently dropped v1-only
selection terms, the Mesh one-pass read could return the wrong schema's
record at a shared key, two decode rules for a foreign schema id disagreed,
dead identity-comparison code from the _projectRecord removal was still live,
and a disposed-socket race could abort a mutation run instead of recording
NO VERDICT. This is Self's fix batch for all of it.
- CultNetSelection.Validate now refuses a blank/whitespace entry inside
schemas or keys, not just an empty array. CultNetSelectionEvaluator no
longer treats [] as "no filter" (the door already refuses it before
evaluation). CultNetV0SelectionLowering is the one place that lowers an
empty or all-blank v0 list to null; CreateRawSnapshotResponse and
CreateShardSnapshotResponse both route through it, and CultMesh's own
Clean() now delegates to it instead of duplicating the rule.
- CreateShardSnapshotResponse no longer carries a private loop or its own
schema/key matching: it builds a CultNetSelection and answers through
CultNetDocumentRegistry.SelectPage (the same evaluator path as the
non-shard snapshot), with the shard's own membership check as a row
filter. Binding-alias expansion now runs on both paths identically.
- ResolveDefaultSelection decides "no schema filter" from the cleaned
recordKeys, not the raw array length. OverlaySelection stops deciding what
a missing override means: any explicit schemaIds/recordKeys override
replaces the default selection wholesale; no override keeps the default
untouched.
- CultMeshSnapshotRequestOptions.Selection is now checked by
EnsureV0Compatible before every send: fields, cites, cited, limit, cursor,
descending, and any non-default projection are refused typed
(CultNetSelectionInvalidException), never silently dropped from the v0
wire message. Mesh sending v1 is FU-Mesh-v1, not this cut.
- CultMeshHotBodySubscription.Selection returns a defensive copy on every
read so its arrays can't be mutated out from under the stored selection.
- ReadDocumentFromSnapshotResponse now searches candidates at one recordKey
in one order - exact schema, then alias, then a foreign schema id decoded
from the payload - instead of one OR'd condition where a case-insensitive
or structurally-lucky candidate could win first. The foreign-id decode
reads the payload's embedded schema version with the registry's shared
CultNetDocumentRegistry.TryReadSchemaVersion and alias-matches it against
the caller's own descriptor directly, rather than resolving to "whichever
registered type owns this schema string" - that resolution is ambiguous
when two document types alias the same schema id (MeshNoteDocument and
MeshNoteAliasDocument in the existing fixtures), which is what broke on
the first pass; TryResolveDescriptorByPayloadSchema stays for its existing
caller (ResolveDescriptorForRawDocument) with a note on when not to use it.
DecodeSnapshotDocuments and the Mesh copy of the payload-schema decode
(RawSnapshotPayloadMatchesSchema/TryReadSchemaVersion) are deleted in
favour of the shared one.
- CultNetDatabaseSubscriptionServer: the dead RecordKey/SchemaId identity
comparisons in Reconcile and ApplyProjectedChange, and the duplicate-key
throw in CreateProjectedSnapshot, are deleted - they can no longer fire
now that a projection's dictionary key is always the record's own key.
A new test flips authorizeRecord per key while the whole request stays
authorized (so DemandActive never toggles) and proves both halves of the
remaining diff: the removal of a now-unauthorized key and the add loop
picking up a newly-authorized one.
- Every NetworkingTests.cs test that starts an IsBackground poll thread now
joins it before its server disposes (about 20 tests; one already did).
- scripts/mutate-dotnet.ps1: a dotnet-test-host crash writing to stderr no
longer throws the whole run away under PowerShell 5.1's
$ErrorActionPreference = 'Stop' (2>&1 on a native command wraps stderr in
a terminating NativeCommandError); Run-Tests catches it and the entry loop
records NO VERDICT instead of aborting every remaining entry.
- Two probe tests: CultNetDatabase_ShardAndNonShardSnapshot_Agree{On
UnfilteredRequest,OnBindingSchemaIdOverride} prove the shard and non-shard
snapshot paths agree, including shard-membership exclusion of a document
outside the shard's key prefix and a binding schema-id override matching
on both paths.
- Mutation entries updated/added to match: the stale NET-D4-FourthMatcher
entry (its anchor was the private loop this batch deleted, not refactored)
is replaced by NET-D4-ShardMembership-Revert; the Mesh entries file's
header miscount ("two of three" loosening entries when there are only
two) is corrected and both previously-surviving loosening mutants
(MESH-DEFAULT-Loosening, MESH-KEY-Loosening) now have killing fixtures;
scripts/mutate-cultnet-selection-fixbatch2.entries.ps1 is new, covering
Reconcile's remaining two mutants.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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 at e5348d3):
- 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>
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>
_projectRecord (CultNetDatabaseSubscriptionServer.cs) reached no production
caller and ran after authorization with no wire consumer. Removed the field,
constructor parameter, and both call sites in Watch() and
CreateProjectedSnapshot(); projection is a value on a v1 selection now, not a
delegate. The two tests that used projectRecord to simulate a record's
identity changing for the reconcile diff
(DatabaseSubscriptionServer_AuthorizesRequestAndFiltersSnapshotAndLiveRecords,
…ReconcilesDeliveredProjectionAndBodyDemandWhenAuthorityChanges) are rewritten
to drive a real rename through the database (delete the old key, put the new
one) instead, so they still prove authorization filtering and the Reconcile()
diff/body-demand behavior.
Self's ruling (docs/cultnet-selection-cut.md, 2026-09-22): the fourth
schema-matching copy at CultNetDatabase.cs, serving CreateShardSnapshotResponse
and SchemaMatchesCatalogFilter (MatchesRequestedSchema + a duplicate
InferSchemaName), collapses into CultNetSchemaAliasMatching.MatchesAny, the one
matcher landed in commit 1.
Networking tests: 162/162 (was 162/162 after commit 1; no count change, two
tests rewritten not added). Negative grep: _projectRecord|projectRecord empty;
MatchesRequestedSchema gone (one remaining substring hit is
RawSnapshotPayloadMatchesSchema in CultMeshSnapshots.cs, the Mesh copy this
commit does not touch — collapses in the next commit).
Mesh collapse (§4/§11 of the map, ~365 gross lines across CultMesh.cs,
CultMeshSnapshots.cs, CultMeshClient.cs, CultMeshBodyDemand.cs) is not in this
commit; it is large enough to build on its own and follows as commit 2b.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/cultnet-selection-cut.md section 2/4/6/7/8: CultNetSelection (schemas,
keys, fields, cites, cited, projection, descending, limit, cursor) with its
door validation (S1), and CultNetSelectionEvaluator: the single-row Matches
fast path (D6) and the full Select (order, hop, cursor, paging). Neither
reflects over a document; both read only through the cache's public surface
(commit 0).
Three v1 messages beside v0 (cultnet.snapshot_request.v1,
cultnet.database_subscribe.v1, cultnet.snapshot_response_raw.v1), their
schema files under contracts/cultnet, registry entries and dispatch arms.
CultNetSchemaAliasMatching goes internal -> public (D4), the one alias
matcher. CultNetDatabase gains LastWriteSequence(schemaId, key), confirmed
at the one append site (AppendMutationLogEntry) that every committed write
takes a shard-log sequence, including a local PutAsync outside any shard.
Three engines deleted, one evaluator wired in their place:
- CultNetDocumentRegistry: CreateRawSnapshotResponse's reflection loop over
cache.AllEntries and MatchesRequestedSchema gone; cache.AllStoredDocuments
replaces the reflection entirely. v0 lowers into a Selection and walks the
evaluator's own pages so a v0 caller still sees its whole matching set
(D3: v0 has no engine of its own). CreateSelectionResponse/SelectPage/
ToRawRecord/CreateSnapshotRequestV1 added.
- CultNetDatabaseServer: CreateChangeMessage/CreateRawDocumentRecord/Matches/
MatchesSchema/InferSchemaName (122 lines) deleted; CreateSubscription lowers
v0 into a Selection and matches through the evaluator.
- CultNetDatabaseSubscriptionServer: CreateAuthorizedChange/CreateChange/
CreateChangeCore/ResolveWireSchemaId/MatchesRequestedSchema/CreateRawRecord
(92 lines) deleted, replaced by CreateMatchedRecord. _projectRecord and
CreateProjectedSnapshot's collapse are commit 2's, per the map's own
deletion line, and are untouched here.
Discrepancy found and fixed (not in the map): CultNetDocumentBinding can
carry a schema id different from its descriptor's own
(CultNetDocumentBinding.ForDocument's schemaId override), which the old
per-engine matchers checked and a descriptor-only evaluator cannot see by
design (it stays decoupled from CultNetDocumentRegistry). Fixed at the two
call sites and in CultNetDocumentRegistry.SelectPage by resolving the
binding's schema id into the effective selection before evaluation
(CultNetSchemaAliasMatching.WithBindingSchemaAlias,
CultNetDocumentRegistry.ExpandSchemaBindingAliases), not in the evaluator.
Found by DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding timing
out; both call sites now pass.
Second discrepancy, reported not fixed: CultNetDatabase.cs:1707 has a fourth,
undocumented copy of schema-matching logic (MatchesRequestedSchema, private,
serving CreateShardSnapshotResponse), which the map's section 1 audit did not
name among "three separate selector engines". Left untouched this commit:
it is out of the map's explicit deletes list and touching it risks shard
snapshot behavior with no budgeted verification.
Verification: dotnet test tests/GameCult.Networking.Tests - 162 passed (150
existing + 12 new), 0 failed; the seven map-named tests all still pass, two
rewritten for the new signatures (CreateChangeMessage now takes
CultNetSelection; CreateChange/CreateAuthorizedChange collapsed into
CreateMatchedRecord, returning CultNetRawDocumentRecord? directly).
tests/GameCult.Mesh.Tests - 252 passed, 1 pre-existing skip, 0 failed (no
Mesh source touched this commit). tests/GameCult.Caching.Tests - unaffected
(188, unchanged from commit 0).
Negative greps: GetProperty|GetField|GetCustomAttribute in
GameCult.Networking - only against the generic `object change` dispatch
(CultNetDatabaseChange<T>) and CultRecordHandle<T> construction, pre-existing
and out of this cut's scope, never against a document's declared values.
_projectRecord survives in CultNetDatabaseSubscriptionServer.cs (expected,
commit 2). CultNetSchemaAliasMatching is the only schema matcher outside the
one reported discrepancy.
scripts/mutate-dotnet.ps1: the caching mutation runner generalized into a
shared dotnet/NUnit harness (Target/TestProject/Entries/ControlFilter),
replacing scripts/mutate-cultnet-selection-caching.ps1 with an entries file
of the same six mutations (re-verified: all six still KILL). It is now
generic to any NUnit project in this repo, not only this campaign's two
targets - a candidate to sit beside tools/eureka-mutations.ps1 as the
dotnet-side counterpart. Nine new networking entries (S3, S5 x2, S7 x2,
S16 x2, S18) all KILLED. Not yet mutation-run: S1, S2, S6, S23 (pinned by
tests, not by a committed mutant); S9-S15 (subscription reconcile lowering,
v0 no-engine-of-its-own); S17, S20 (pinned by tests only). S19/S21/S22 are
cache-layer, covered in commit 0.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The generator and CultDocumentRegistry now discover members by the same
steps: most-derived declaration first, override chain collapsed to one
member, attributes read up the override chain, rejections in one order
with one text (GCC001 / InvalidOperationException).
- Accessors are named per document, so documents sharing a [CultName] or
[CultIndex] base member no longer emit duplicate methods (CS0111).
- Duplicate [Key] slots are refused, including new-hidden fields and
properties; a hidden persisted member with a different slot is refused.
- String [Key("x")] gets its own message instead of "has no [Key]".
- A persisted property whose set accessor is private, protected or init,
or a readonly field, is refused instead of CS0272 in generated code.
- Overrides: MessagePack's DynamicObjectResolver reads the base
declaration's [Key]/[IgnoreMember] (it throws "key is duplicated" for a
re-keyed override and serializes an override marked [IgnoreMember]).
An override that changes them is refused in both paths; an override
that repeats or omits them is accepted and byte-pinned.
- Typed Serialize<T>/Deserialize<T> used static Options and skipped
generated codecs and declared resolvers; deleted. CultNetDocumentBinding
and CultMeshSnapshots route through the untyped path with their registry.
DocumentShapeSweepTests compiles each shape through the generator and
compares generated vs reflective schema id, catalog, name/index values
and payload against MessagePack's own bytes, or equal rejection text.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bcae483 made the cache a delivery scheduler (tickets, _deliveryGate turn
waits, per-thread delivery queues). It deadlocked inside CultLib: a load's
delivery reaching a CultNetDatabaseSubscriptionServer observer that needs
_lifecycleGate, while a subscribe's demand handler holding that gate writes
the cache and waits for the delivery's turn.
The cache now owns order as data. Every admitted change takes the next value
of a per-cache in-memory sequence under the gate, published as
CultCacheDocumentChange<T>.Sequence. Delivery is the 544c087/b653f68 rule:
each call publishes its own changes on its own thread after releasing the
gate; nothing waits on another call's delivery. Cross-thread delivery order is
not guaranteed.
Deleted: ticket counter, _delivered, _deliveryGate, Monitor.Wait turns,
per-thread delivery queues, per-ticket catch-all, and the save-order contract
text with its observer-must-not-block rule.
Consumer: a cache-backed CultMesh document handle hands its observed and
reactive mirrors each snapshot's Sequence; ApplyCanonicalSnapshot drops one
not newer than the last applied.
Tests: SequenceIncreasesInAdmissionOrder,
ObserverWritingTheSameCacheDoesNotWaitOnOtherDeliveries,
SubscriptionDemandHandlerWritingCacheDuringDeliveryDoesNotDeadlock,
WatchRecordIgnoresStaleSequence. The last three time out on bcae483.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- NetworkingTests.CultNetPutInsideCommitPublishesOnlyAfterCommit: a record
staged in a batch reaches no CultNet observer before the store commits; a
put made while staging is its own write. Chosen over refusing CultNet writes
inside a batch, which would need ambient "inside a batch" state.
- Interop peer mode write-routed <catalog.cc> <run.cc>: cultcache.interop-note
routes to the catalog file, cultcache.interop-run-note to the run file, one
record each.
- store-composition contract: the C# column reads implemented for routing,
attach, schema, lookups, globals, batch and conditional commit; overwriting
a store is open, remove, upsert, flush. persistence-format: C# implements
batch and conditional commit.
- tests README lists the new suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ExecuteTransactionAsync (both overloads), RequireTransactionsForAuthoritativeWrites,
EnsureAuthoritativeTransaction and the AfterCommit deferral had no consumer
outside CultNetDatabase_RequiredTransaction_DefersRawPutPublicationUntilCommit,
deleted with them. Writes publish immediately, as they already did outside a
transaction. The cache's own transaction machinery stays until Cut 3 replaces
it with the explicit batch commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deletes four confirmed-dead tracked files:
- CultLib.txt, src/GameCult.Caching/GameCult.Caching.txt,
src/GameCult.Unity/Assets/UI/UI.txt: concatenated snapshot copies of live
source, carrying stale E: drive provenance and obsolete dependency
versions. Live source under src/ remains the owner.
- tests/GameCult.Networking.Tests/lcov.info: tracked coverage output.
.vscode/tasks.json:14 still generates it fresh via
"/p:CoverletOutput=./lcov.info" on every coverage run, so tracking the
stale committed copy in git is the liability, not the generation step.
- .voidbot/status/void-memory-maintenance.json: a one-time "starting"
receipt (added whole in 4813570, never touched again) naming a dead
E:\Projects\CultLib path and an unimplemented repo_face_heartbeat
launcher. VoidBot's actual heartbeat state lives at
.voidbot/status/repo-face-heartbeats.{cc,json} (see
packages/config/src/index.ts REPO_FACE_HEARTBEAT_STATE_PATH) — a
different file entirely; nothing reads this one.
Kill attempts:
- git grep -n for each of "CultLib\.txt", "GameCult\.Caching\.txt",
"UI\.txt", "lcov\.info" across origin/main, excluding each file's own
path: only .voidbot birth/distiller snapshots (auto-generated onboarding
file-listing catalogs, not functional readers) and the one real lcov.info
producer in .vscode/tasks.json.
- rg across all of F:/Projects (excluding target/node_modules/.git):
gamecult-site's docs/research/gamecult-org-repo-ledger.json lists
"CultLib.txt" as a filename in a static historical repo-file inventory
(not a functional consumer); Aetheria/docs/cultcache-migration-cut.md
cites GameCult.Caching.txt:157 as an evidence line number in a
migration-history note (stale citation after this cut, not a functional
break — flagged for the record, not fixed here since that doc is out of
scope).
- voidbot MCP search for void-memory-maintenance.json / repo_face_heartbeat
found only the unrelated repo-face-heartbeats state path, confirming no
reader.
Collision check: git diff --stat origin/main against each of the other 4
CultLib worktrees (codex/recover-aetheria-authority,
codex/delvehold-isosurface, claude/media-stream-catalog, and the two
detached-HEAD worktrees, both confirmed ancestors of origin/main) over
these five paths: all empty, no in-flight work touches them.
REC-102
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>