Soul had this green in the rig at 376 bytes and called it "blocked on
unbuilt page-assembly ownership" - the wrong thing: only the two
*evaluators* producing the same page from the same rows needed that move
(a separate claim, R-AR); the two *encoders* producing identical bytes for
the same page value never did. Land the whole
cultnet.snapshot_response_raw.v1 envelope, byte-identical between Rust's
encode_cultnet_message_to_vec and C#'s CultNetSnapshotResponseRawV1Message,
on both runtimes.
The existing header/edge vectors also proved nothing about tags
(string[] vs Vec<String>) or payload (byte[] vs serde_bytes): both were
absent at every value pinned. Add a header with tags populated and an
edge with a payload populated, captured the same way against a real
MessagePack C# 3.1.7 run on Yggdrasil. Neither diverged - the two
mappings agree byte-for-byte.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul's merge-gate third pass (SM-7) found three live mutants: dropping
to.schemaId/to.recordKey from the edge comparator (both runtimes) and
dropping recordKey from Rust's row comparator (named a pass ago, never
touched). Hands' earlier argument that no selection ties from+role while
leaving to free to vary was false - a selection carrying both cites and
cited mixes their edges into one bucket, and a self-citing peer produces
exactly that tie. Take the counterexample from Soul's rig rather than
rebuilding it, and add the missing row-comparator recordKey tiebreak test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Decided the payload shape: a map with camelCase keys, fields in declared
order, every optional field always present and nil when absent - never
omitted. C#'s [MessagePackObject]/[Key("name")] map formatter already does
this by default (confirmed byte-for-byte below); Rust's `to_vec_named`
encoding for these four types used `skip_serializing_if = "Option::is_none"`,
which omits the key instead - the gap the map cited ("C# writes nine fields
with nils, Rust omits absent ones").
Removes skip_serializing_if from Edge, RawDocumentHeader,
SelectionDocumentRecord and SelectionPage, keeping #[serde(default)] for
decode-side robustness. Selection (the request type) is untouched - out of
this ruling's scope, and the map already confirmed the request side agrees
between runtimes.
Adds tests/selection_wire_parity.rs: RawDocumentHeader and Edge, each
carrying several absent optionals, encoded via to_vec_named and compared
byte-for-byte against hex captured from a real MessagePack C# 3.1.7 run
(same pattern as error_message.rs's csharp_hex vectors) - both now agree
exactly.
What is NOT done, and why: R-AP also called for "Select gains projection,
so the thing the vectors test is the thing that decides." In C#, projection
(header vs document, ToRawRecord/ToEdge/FromRecord) is owned entirely by
CultNetDocumentRegistry.SelectPage/SelectAll (GameCult.Networking), not by
CultNetSelectionEvaluator.Select/Page - the evaluator returns raw Rows/
EdgeMatches and knows nothing about a document's payload serializer, which
only the registry's bindings own. Moving page assembly into the evaluator
without threading a caller-supplied serializer (the same pattern ordinalOf/
shardIdOf already use) would give a schema-generic evaluator per-binding
wire knowledge it should not own; threading a serializer callback in is
buildable but changes SelectPage/SelectAll's own signature and every
caller, plus the parity-vector harness's construction - a materially larger
change than this pass's remaining scope. No vector yet exercises the real
CultNetSnapshotResponseRawV1Message/select_page output byte-for-byte; only
the two nested wire types in isolation. Flagged for the operator per the
brief's own stop condition.
R-AQ, updated: type-level byte parity for the nested wire shapes (Edge,
RawDocumentHeader) is now real and vector-tested. Whole-message byte parity
for cultnet.snapshot_response_raw.v1 is still not - the smaller claim (this
cut's page bytes agree only where R-AH's fixed-shape rule reaches, and
projection ownership is still split between the evaluator and the registry)
is the true one until "Select gains projection" actually lands.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
R-AN: the existing edge-order test ties From and To across its two edges,
so it only pins from.record_key as sole discriminator - dropping role
entirely still survives (Soul's SM-4). Two new tests isolate the other two
components of EdgesFor's comparator: role (same citer, same target, two
roles - Citation.role: None matches any reference) and from.schema_id (two
different-schema citers sharing one record key and role). Both confirmed to
kill their own dropped-clause mutation by hand (reverted after).
R-AO: shared_key_with_two_in_target_candidates_resolves_to_whichever_the_-
caller_listed_first pins "first row in the caller's order wins" - R-AF's
existing vector only covered one-in/one-out. Confirmed to kill a
last-wins mutation by hand (reverted after). Also extends the shared vector
case list with shared_key_design_resolves_the_first_in_target_candidate_-
in_row_order (paired with the fixture/vector commit).
To.SchemaId/To.Key were re-checked per Self's ruling: within any one
page_position bucket, `cites` always ties every edge's To to the query's
own single target, and `cited` always ties it to the anchor row's own
identity - the only way for To to vary at a tied page_position is a mixed
cites+cited hop on one row, which always ties or untangles via From first.
No construction was found where To is the sole discriminator; the earlier
"equivalent within one hop direction" claim holds. No code change, no
artificial test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same gap as render_f32's sign-bit test (previous commit): render_f64's
sign extraction has the identical shape and the identical coincidence
(-1.0_f64/-2.0_f64's bit 0 happens to be 0, matching their real sign
bit's positive-looking value under a wrong-direction shift).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every existing render_f32 test (including the two added in the previous
commit) used a positive value, so a mutant that shifts the sign-bit read
the wrong direction (bits << 31 instead of bits >> 31, reading bit 0
instead of bit 31) went unnoticed: 1.0_f32 and 2.0_f32 both happen to
have bit 0 equal to 0, same as their real (positive) sign bit, so the
mutant computed the same "not negative" answer by coincidence.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The first canonical_number mutation pass (previous commit) closed every
survivor except four in render_f32 (biased_exponent == 0 flipped to !=,
the subnormal exponent's -149 losing its sign, and the normal path's
implicit-bit | flipped to ^/&). The existing round-trip tests parse the
rendered string back through f32::parse and compare bits, which can
still agree with a wrong bit-extraction when the wrong value happens to
parse back to the bits it was carelessly derived from; none of them
assert on the rendered string's shape or magnitude directly.
Add a subnormal-shape test (2^-149 is "0." plus 149 fraction digits, not
an astronomical integer and not one exponent off) and a normal-path test
where the fraction bits are identical to zero's (1.0 and 2.0) so only
the implicit leading bit the | folds in carries the value.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cargo-mutants over selection.rs found every branch mutation in
is_canonical surviving (the Q-J door was reachable only through the
handful of spellings validation_refuses_non_canonical_number_spellings
happened to cover), plus decompose/pad_right/canonicalize_decimal_digits/
big_mul_small/big_to_most_significant_first each replaceable with a
trivial stub while the suite stayed green.
Add tests that call is_canonical, decompose, pad_right, compare,
canonicalize_decimal_digits, big_mul_small and big_to_most_significant_first
directly (the nested test module already reaches private helpers via
super::), pinning exact output at the inputs that exercise each branch,
comparison and carry step named in the mutant list rather than only the
few spellings the field-validation door happened to already cover.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
R-AF: FixtureRow's references (and every fixture/hand-built row that
constructs them) carry a bare target key now, matching Row::references's
new contract. Adds citer_narrow() (the shared fixture's real schema id,
distinct from this file's own local NARROW_CITER) to the fixture
machinery, and two direct behavioural tests
(shared_key_resolves_to_the_row_inside_the_declared_target_in_either_row_order,
shared_key_refuses_when_the_only_candidate_is_outside_the_declared_target)
pinning both shapes from Soul's table at the select() level, plus a
shared-fixture vector case for the accept shape and an
evaluation_refusal_cases entry (expected_evaluation_refusal, read by the
matching C# addition) for the refuse shape.
Rewrites cites_refuses_a_row_carrying_an_unrelated_edge_outside_its_declared_target
(renamed cites_refuses_an_out_of_target_edge_under_the_same_queried_role):
its original shape queried one role while expecting refusal from an
edge under an unrelated role, a stronger guarantee than C#'s own R-T(F15)
test gives (same role, two elements) - found because it broke once
matches_citation's role-filter order was fixed to match C#. The new
version mirrors C#'s actual test shape.
R-AG: hostile cursor tests feeding Cursor::parse a length prefix near
usize::MAX (Rust's own proven overflow), near i32::MAX (C#'s proven
overflow, fed here too per the ruling - neither side's tests are
evidence about the other), a prefix wider than any integer type, and a
negative-looking prefix - all under std::panic::catch_unwind, asserting
a typed CursorInvalid and never a panic.
R-AJ: edge_order_is_from_key_before_role_when_both_vary_at_the_same_anchor
(a both-hops selection with a hub/other citer pair whose keys and roles
are deliberately anti-correlated, so a Role-first mutant and the real
rule disagree) and
row_tiebreak_orders_by_schema_id_before_record_key_when_both_vary_at_a_tied_ordinal
(the same anti-correlation trick applied to order_rows, computed against
the real leaf_a/leaf_b ids at runtime).
FixtureRow gains #[derive(Debug)] - needed for an assert message on the
new evaluation-refusal-case writer, harmless elsewhere.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The fixture had zero duplicate record keys across its ten rows and no
ordinal tied across two different schemas - both shapes this fix batch's
rulings needed and neither runtime's parity vectors ever covered.
R-AF adds a "citer_narrow" schema (real SHA-256 id, computed the same way
CultDocumentRegistry does - a probe run against a type matching the
eventual C# type's exact namespace/nesting confirmed it) whose one
reference targets leaf_a only, plus rows for both shapes from Soul's
table: a lone out-of-target row at a key (refuses), and two rows sharing
a key where only one is in-target (accepts, resolving the in-target one
regardless of row order).
Also drops "targetSchema" from every reference entry: it was never
anything but documentation (SelectionParityVectorTests.BuildDocument and
cultnet-rs's build_row both already read only targetKey) and keeping a
field the wire does not actually have would contradict the rule R-AF just
established.
R-AJ adds a tied-ordinal pair across leaf_a and leaf_b, with keys chosen
opposite to the real schema ids (probed) on purpose, so a schemaId-first
order and a recordKey-first order are forced to disagree regardless of
which real id happens to sort first - see the row-tiebreak test this
enables.
selection-vectors.cs-written.json and selection-vectors.rs-written.json
are regenerated from the new fixture (CULTNET_WRITE_VECTORS=1 on both
sides) and cross-checked in both directions locally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
R-AF. Probed the real wire bytes a stored CultRecordRef<T> produces
(GameCult.Caching.MessagePack.CultRecordRefFormatter<T>.Serialize writes
only value.Key.Value - a probe against a real document confirmed the
serialized "Design" field is the bare msgpack string "a-eq", nothing
else). The wire carries no schema for a reference; C#'s rule was right
and Rust's exact-(schema,key) lookup assumed a schema id the edge never
actually carries.
Row::references() now returns (role, target_record_key, payload) - a
bare key, matching C#'s ReferencesOf. matches_citation/build_incoming_index
resolve through the new resolve_reference_target, which mirrors C#'s
TryResolveReferenceTarget: search every row sharing the key (by_key is
now keyed by record_key alone, grouping rows across schemas) for the one
whose schema falls inside the declared leaf set, refusing deterministically
(by schema id, code-point order) when none do. Folds D9's leaf-set check
into resolution itself rather than a second ensure_within_declared_target
pass - deleted, since it also dropped the old bypass-on-empty-leaves
behaviour C# never had (empty leaves always refuses when a candidate
exists).
Also moved the citation's own role filter before resolution in
matches_citation (mirroring C#'s ReferenceMembers(descriptor,
citation.Role), which narrows to that role's members before R-W's
per-edge check ever runs) - found while landing R-AF's vectors: Rust
was checking every declared reference a row carries regardless of the
requested role, a stronger and different guarantee than the C#
reference actually gives.
R-AG. read_length_prefixed_fields used unchecked usize addition
(pos + len) to bound a length prefix parsed from the wire; a hostile
prefix near usize::MAX overflowed and panicked. Uses checked_add now,
returning None (which Cursor::parse already turns into a typed
CursorInvalid) the same way every other malformed shape here already
does.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rebasing onto the C# side's landed R-X work (fix batch 4) picked up
changes to contracts/cultnet/cultnet.error.schema.json that predate the
vendored copy this branch added. The drift test added for R-AB caught
it immediately: re-copied, no other vendored schema or the vendored
fixture differed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
actually runs
The schema_discovery.rs include_str! escape was not the only one:
tests/selection.rs's fixture() read
../../contracts/cultnet/interop/selection-vectors.fixture.json, the
same three-levels-up path shape, and 37 of 45 tests panicked on it
under cargo-mutants' isolated copy - confirmed on Yggdrasil after the
schema_discovery.rs fix alone still landed "cargo test failed in an
unmutated tree, so no mutants were tested".
selection-vectors.fixture.json is a checked-in input (not a generated
artifact), so it gets the same treatment as the 22 schemas: a
byte-identical vendored copy under
packages/cultnet-rs/contracts/cultnet/interop/, a drift test
(fixture_contract_matches_the_canonical_source, same skip-when-absent
shape as schema_discovery_contracts.rs), and the loader reads the
vendored copy unconditionally.
selection-vectors.cs-written.json is different in kind: it is the C#
reference's own generated output, produced only by a real cross-runtime
checkout running SelectionParityVectorTests with
CULTNET_WRITE_VECTORS=1. There is nothing to vendor - an isolated
Rust-only copy can never satisfy that test's premise regardless of what
this crate does with its own files. That one test now skips (with an
explanation) when the canonical contracts/ tree is entirely absent,
rather than panicking and failing the whole suite over a cross-runtime
artifact the isolated copy cannot produce by construction.
Verified locally by hiding contracts/ entirely and rerunning the full
suite (217 tests across every binary): unchanged pass count, same as
with it present.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
schema_discovery.rs embedded the 22 contract schemas via
include_str!("../../../contracts/cultnet/*.schema.json"), three levels
above src/ and outside packages/cultnet-rs entirely. There is no
workspace Cargo.toml tying this crate to its siblings, so cargo-mutants
treats this package's own directory as the whole tree it copies - every
one of those paths escaped it, and the unmutated baseline build failed
on all 22 with "No such file or directory". There was no mutation
evidence for the Rust half of this cut at all.
Fix: vendor byte-identical copies of the 22 schemas under
packages/cultnet-rs/contracts/cultnet/, git-tracked so they travel with
the package, and repoint every include_str! at the vendored copy. A
build script staging files into OUT_DIR was the other option named for
this rule, but it does not actually solve the problem: the script would
still have to read from outside the package to find its source, and
would fail the same way inside cargo-mutants' copy. Vendoring is
"another route that keeps every source path inside the package."
Vendoring duplicates files this repo treats as canonical
(contracts/cultnet/*.schema.json, shared across every runtime per
docs/cultnet-selection-cut.md section 1), so a new test,
schema_discovery_contracts.rs, compares every vendored file against its
repo-root original byte for byte and fails loudly on drift. It skips
(not fails) when the canonical contracts/ tree is not present beside
the package - the isolated-copy case R-AB exists to survive - so the
check cannot reintroduce the same escape it guards against.
cargo-mutants run on the crate, at least covering this cut's diff:
report to follow with every survivor triaged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cursor::compute_digest hashed the selection alone (cursor/limit cleared),
so a caller holding one valid cursor could splice its genuine digest onto
a body naming a different asOf/ordinal/schemaId/recordKey and reuse it -
the digest never bound the cursor to the position it names.
compute_digest now takes (asOf, ordinal, schemaId, recordKey, selection,
key) and folds the body into the same length-prefixed canonical string
before hashing; mint and verify_digest pass their own body through.
Tests: an in-module unit test (cursor_tests, next to Cursor, where the
private digest field and the length-prefix helpers are reachable) splices
a genuine digest onto a rewritten ordinal and a rewritten record key and
confirms verification fails on both; an integration test confirms two
cursors minted for different positions under the same selection and key
never share a digest.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
edge is anchored by its own direction
R-V (S-1): select()'s row index and the cited incoming index were both
keyed by record key alone, so two schemas sharing one key collided -
which row a `cites` edge resolved to, and whether a `cited` selection
counted a row as cited, depended on row order. Both indexes are now
keyed by (schemaId, recordKey), matching CultCache's per-schema key
uniqueness.
R-W (S-3), Rust half: a selection carrying both `cites` and `cited`
computed one `cited_direction` flag and applied it to every edge in the
batch, so the `cites` edges vanished whenever `cited` was also present.
Each `EdgeMatch` now carries its own `EdgeAnchor` (Citer/Citee) set at
the point it is produced, and the page filter/sort anchor each edge by
its own hop instead of one shared direction.
Tests: two schemas at one record key, in both row orders, agree on
which row a citation resolves to and which row counts as cited; a
selection with both hops returns edges from both directions.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
They shipped without ever being added to the public surface list.
Scalar-only is noted explicitly: every other scalar in math.cs has
float2/3/4 overloads, erf/erfinv do not, and that stays a separate
scope decision since the only consumer (Aetheria fire control) is
scalar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The round-trip test (ErfinvInvertsErf) was the only defense of
erfinv's accuracy, and it is blind by construction: it can only prove
erf and erfinv agree with each other, not that either is correct. A
hand mutation of a single tail coefficient degraded erfinv's worst
error 300-400x (5.0e-7 to 2.0e-4) and every committed test still
passed. Added ErfinvMatchesReferenceValues, pinned against reference
values from bisecting the platform's double-precision erf (Python's
math.erf, glibc-backed) rather than this package's own erf, out to
|y| = 0.9999, at the same 2e-6 absolute bar erf's own reference test
carries. Expected values are computed for the actual float32-rounded
input, not the decimal literal, because erfinv's derivative diverges
near +-1 and that rounding alone moves the true answer by more than
the error bar if ignored.
Verified against three hand mutations, each restored byte-exactly
afterward:
- erfinv tail leading coefficient 2.83297682f -> 2.83317682f: killed
(4 assertions failed).
- erfinv tail second coefficient 1.00167406f -> 1.00187406f: killed
(4 assertions failed).
- erf's `p` coefficient 0.3275911f -> 0.3275930f: survives. Soul
measured this one 2.3x worse but still under the 2e-6 bar the spec
pins; this is not a fixture gap, it is the mutant staying inside the
accuracy envelope the tests are supposed to allow. Recorded here
rather than tightening the bar past what the spec asked for.
Added ErfinvAtDomainEdgesAndBeyond, pinning the domain and edge
behavior from the matching source comment: erfinv(1) = +infinity,
erfinv(-1) = -infinity, and NaN outside [-1, 1] including +-infinity
and NaN input.
Deleted ErfinvIsMonotonicallyIncreasing: it asserted strict
monotonicity, which erfinv does not have. Scanning every representable
float32 in [0, 0.999999] finds 9662 backward steps, first at
y = 0.00022214651, worst drop 5.960464e-8 (one ULP at that scale) --
invisible only because the old fixture stepped by 0.001. erf has the
same shape: 100 backward steps over [0, 4.5], worst drop 1.192093e-7.
Replaced it with two tests: ErfinvIsMonotonicWithinFloatingPointTolerance,
pinning monotonicity to within 1e-6 (comfortably above the measured
noise floor), and ErfCellMassesStayNonNegativeAcrossRealisticGeometries,
the consumer-level property that actually matters for fire control:
differences of adjacent erf values (cell probability mass) stay
non-negative across 16/64/256/1024/4096 cells over +-4 sigma, plus a
tight 1 sigma spread and a very tight 0.25 sigma spread.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
math.erfinv(1.0f) returned -infinity and erfinv(-1.0f) returned
+infinity: at value = +-1, w = -log((1-value)(1+value)) = -log(0) =
+infinity, the tail branch's leading coefficient is negative, and
`return p * value` delivered the inverted infinity. erf saturates to
exactly 1.0f at 5.543 sigma, so any interval edge beyond that reaches
this bug in ordinary use.
Guard both edges explicitly before the polynomial runs: erfinv(1) is
now +infinity, erfinv(-1) is -infinity, and anything strictly outside
[-1, 1] (including +-infinity) is NaN, which is the correct answer and
was already the fallthrough behavior. Documented the domain and the
edge behavior in a comment, matching the house style `sign` already
uses at math.cs:84. The approximation itself is unchanged.
Also recorded the true measured accuracy beside erf and erfinv: worst
absolute error 6.621e-7 and 5.066e-7 respectively, both inside the
2e-6 bar this package's tests pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CultMath.Tests is on xunit.v3. Stryker's default vstest runner cannot
capture coverage against xunit.v3's test host and hangs retrying test
sessions indefinitely (observed: 2096 mutants queued, zero completed after
~9 minutes of retries). Stryker's Microsoft.Testing.Platform runner
(-t mtp, still preview) works correctly against xunit.v3 and completed the
same 1351-mutant run in ~4.5 minutes. Pin it in config so a plain
`dotnet stryker` works without a CLI flag.
Add docs/mutation-testing.md: how to run it, the --since form for a single
cut, the triage rules, a known coverage-capture caveat in the mtp runner
(confirmed by hand-mutation against static readonly fields), and the
packages still needing their own config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scope mutation testing to CultMath.Tests only, matching Aetheria's
stryker-config.json shape: Standard level, progress/html/json reporters,
break threshold 0 (score is not a gate). Mutate only CultMath's own
sources, excluding the generated Swizzles.g.cs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the pinned accuracy bar against tabulated erf values (0, +-0.5,
+-1, +-2, +-3, within 2e-6), odd symmetry, the +-1 limits, erfinv's
round trip through erf over y in [-0.999, 0.999], and erfinv monotonicity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fire control's Gaussian scatter needs erf for a cell's probability mass
and erfinv to draw the lateral offset, both called per shot. Abramowitz-
Stegun 7.1.26 for erf (stated max abs error 1.5e-7), Giles 2010 (GPU
Computing Gems) for erfinv. Neither is an HLSL intrinsic, so there is no
shader-parity obligation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CultNetMessage::Error gains typed code (CultNetErrorCode) and details
(CultNetErrorDetails) fields mirroring the landed C# reference
(CultNetErrorMessage/CultNetErrorDetails, CultNetSchemaMessages.cs). The
message now encodes/decodes through the raw cultnet.schema.v0 path
(encode_raw_cultnet_schema_message/parse_raw_cultnet_schema_message)
instead of the generic serde_json::Value round trip every other
CultNetMessage variant uses, because that path's BTreeMap-backed
serde_json::Value would sort keys alphabetically and break the C#
reference's fixed schemaVersion/error/routingHint/code/details map
order - every optional key is written nil, never omitted, matching
MessagePack C#'s always-five-key formatter.
Adds the SelectionRefusal -> CultNetMessage mapping the fix-batch-3
comment in selection.rs called out as pending on the C# shape landing,
covering all four codes (selection_invalid, cursor_stale, cursor_invalid,
reference_outside_target).
cultmesh-rs's one CultNetMessage::Error match gets an added `..` so it
still compiles against the wider variant; it does not read code/details.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Self's sequencing note (2026-09-22): the C# R-N pass (code/details on
cultnet.error.v0) is running separately in C:\ws3 and will report the
exact field names and code strings once it lands. Do not guess them.
Reverts the earlier Error{code,details} fields on CultNetMessage, the
SelectionRefusal::wire_code/wire_details/to_wire_message mapping, and
their five tests. R-O, R-T, R-U's hand-probes and R-R are unaffected -
none of them depend on the wire error shape. When the C# R-N commit
lands, pull it, read its exact field names and codes, and implement the
Rust mapping against those instead of a guess.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/cultnet-selection-cut.md, Self's rulings for fix batch 3 (2026-09-22),
R-R: "the Rust v0 lowering must match the C# pre-cut v0 behaviour" -
recordKeys: [] answers empty, and the requested key order is kept. The
parallel C# pass landed the same correction on the reference server at
5e33d81/1afbaed/f8ba2d0 (pulled via rebase); this is the Rust side of it.
lower_v0_list (packages/cultnet-rs/src/snapshot_query.rs) used to collapse
a present-but-empty-after-trimming schemas/recordKeys list down to `None`
("no filter"), turning `recordKeys: []` into "every row" instead of the
pre-cut server's actual answer of nothing. It now returns `Some(vec![])`
for that case; the shared evaluator's matches_schema_keys_fields already
treats a present-but-empty list as "match nothing" (R-E), so no further
door/short-circuit logic was needed here - unlike the C# reference,
serve_read_only_raw_snapshot never calls validate, so there is no v1 door
for this shape to hit (the R-R fixup at 1afbaed doesn't apply to Rust).
serve_read_only_raw_snapshot also now restores the caller's requested
record-key order on the response (a stable sort by position in
record_keys), matching the pre-cut server's HashSet<string>
insertion-order iteration instead of leaking plain snapshot-source order.
Rewrote the stale test that pinned the old (wrong) "empty/blank list is no
filter" behaviour into
serve_read_only_raw_snapshot_omitted_v0_schema_list_is_no_filter_present_empty_or_blank_answers_empty,
and added serve_read_only_raw_snapshot_keeps_the_requested_record_key_order.
Hand-verified both by reverting each fix locally and confirming its own
test fails.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The source and test changes for the previous commit's message - split out
because the harness-deletion staged from an earlier `git rm` committed
alone before these paths were added. Same ruling, same scope: R-N's wire
code/details, R-O's keyed length-prefixed cursor, R-T's every-edge cites
check, and their behavioural tests (including the hand-verified mutants:
reverting each of the three kills its own new test).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Self's ruling, 2026-09-22 ("the C# reference's alias rule is the rule"): an
alias is a schema *name* with a trailing .vN suffix, resolved against the
descriptor's declared name - never against a hash id. C#'s production call
sites (CultNetSelection.cs:438, CultNetSelectionEvaluator.cs:88,340) all use
the descriptor overload of CultNetSchemaAliasMatching.Matches, never the bare
id-vs-id overload.
- Row gains schema_name() (defaults to schema_id() for rows with no separate
name, e.g. raw wire records); RowSet gains schema_name(schema_id) ->
Option<String>. Both stand in for CultDocumentDescriptor.SchemaName, which
this crate has no type for (D7).
- schema_alias::matches/matches_any now take (candidate, schema_id,
schema_name) and resolve a candidate's inferred name against the declared
name, exactly as the C# descriptor overload does - not against schema_id's
own inferred name, which is what the deleted hash-stripping matcher did.
- Every call site (reachable_schemas, the cites.target door check,
matches_schema_keys_fields's D6 fast path, matches_citation) now resolves
and passes a schema's name instead of comparing two schema-id strings.
- Deleted: the hash-stripping schema_alias::matches(candidate, schema_id)
two-string overload. A hash-shaped alias ("<hash>.v1") is not a wire form
either runtime's production rule ever resolves.
Behaviour verified against a standalone run of the exact C# algorithm
(CultNetSchemaAliasMatching, CultNetDatabase.cs:47-97) covering exact-id
match, bare-name match, .vN alias resolution, MatchesAny's empty-matches-all
convention, ordinal case sensitivity, ".v" with no trailing digits, and
multiple ".v" markers (last one wins) - every case is now a Rust unit test
in schema_alias::tests.
Test fixture (tests/selection.rs) updated: the fixture's hashAlias entries
are now a shared negative check (neither runtime resolves them), and the
nameAlias entries now resolve in Rust too, fixing the confirmed cross-runtime
defect (schemas_by_name_alias). Regenerated
contracts/cultnet/interop/selection-vectors.rs-written.json (15 -> 16
vectors): added refuses_cites_target_by_hash_shaped_alias, renamed
hop_by_role_cites_hash_alias to hop_by_role_cites_name_alias.
cultnet-rs: cargo test 223/223. C# reader (rs-written.json): 14/16 - the two
failures are both the pending unmatched-cites.target door refusal
(CultNetSelection.cs:361-391 has no such check yet), not a Rust defect; the
other Hands' C# door fix covers both.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Companion to the C# side's shared-fixture commit. Replaces the
invented fake sha256-shaped LEAF_A/LEAF_B/CITER/LEAF_A_ALIAS consts
with lazily-loaded values read from
contracts/cultnet/interop/selection-vectors.fixture.json (a
std::sync::OnceLock-backed loader; FixtureRowSet's declared_indexes/
declared_roles/is_numeric/target_leaves become runtime string
comparisons instead of const match patterns). NARROW_CITER stays a
local, unshared id - it never appears in a cross-runtime vector.
base_rows() keeps S2-S23's original 7-row set (now sourced from the
fixture's first 7 rows, unchanged in value or order, so every test
that enumerates it - orders_by_ordinal_then_identity_and_reverses,
pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor,
matched_is_the_total_count_not_the_page_count, ... - still holds
unchanged). A new all_fixture_rows() carries all ten rows (the
fix-batch float-tie and astral/BMP rows too) for the two parity-vector
functions only.
write_selection_vectors_for_the_reference gains float_tie_exact_ge,
astral_key_code_point_order, and the fixture's hash alias
(leaf_a_hash_alias(), a synthetic ".v1" this crate's own schema_alias
resolves) and name alias (leaf_a_name_alias(), "leaf_a.v9" - the form
only CultNetSchemaAliasMatching's descriptor overload resolves)
probes, mirroring the C# side. selection_vectors_written_by_the_
reference_decode_and_evaluate_identically_in_rust no longer panics on
the first disagreement (evaluate_vector now returns a Result instead
of .expect()'ing); it collects every vector's mismatch and fails once
with the full list, matching the C# reader's same change.
Regenerates selection-vectors.rs-written.json from the shared fixture.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- contracts.rs: replaces the ~335-line hand-written require_*/encode_*
codec for SelectionDocumentRecord/RawDocumentHeader/Edge/RecordRef with
two generic helpers (require_typed/encode_typed) that go through
rmp_serde::to_vec_named/from_slice and rmpv::Value's own Serialize -
these four types already derive Serialize/Deserialize with the wire's
camelCase field names. rmpv::ext::to_value/from_value was tried first and
rejected: it serializes a struct positionally (one array slot per field
in declaration order), which breaks the moment a skip_serializing_if
field is actually skipped and is not the named-map encoding this wire
always uses. Proven both ways by
tests/selection.rs's snapshot_response_raw_v1_round_trips_* tests, which
round-trip real headers, documents-with-payload, and edges with and
without a payload through the real cultnet.snapshot_response_raw.v1
message envelope.
- snapshot_query.rs: deletes reject_duplicates and its two call sites -
dead, because lower_v0_list already dedups (via its own HashSet) before
reject_duplicates ever runs, so its duplicate check can never fire.
(RawDocumentHeader::from_document, the Eq derives on the selection wire
types, and two dead branches the mutation harness's own first run proved -
exact_decimal_from_mantissa's redundant "if mantissa == 0" guard and
big_mul_small's unreachable multi-digit carry loop - are selection.rs
changes already covered by the prior commit in this batch.)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Rust half of the Cut 1 fix batch (docs/cultnet-selection-cut.md, "Self's
rulings for the Cut 1 fix batch, 2026-09-22"), against packages/cultnet-rs.
- R-B: hop edges now follow the hop's direction - cites keys an edge off its
From (the citer), cited off its To (the citee), each in page-row order
then (from, role, to) code-point order. Before this, both hop kinds keyed
off From, so a cited selection's edges were silently empty unless the
citer also happened to be on the page (Soul's F2). build_incoming_index
iterates the caller's own row slice, never a HashMap, for its own order.
- R-C: code-point order was already Rust's default str::cmp (UTF-8 byte
order); no change needed there.
- R-D: canonical_number::render_f32/render_f64 render the exact IEEE-754
decimal expansion from mantissa and exponent, via a small fixed-point
bignum (~60 lines), replacing the previous shortest-round-trip-form path
that rounded ties differently from .NET. expand_scientific_notation and
its test are gone (R-M); the Display probe test is kept per the map.
- R-E: schema_alias (ported from CultNetSchemaAliasMatching's string
overload) is the one schema-identity rule for `schemas` and
`cites.target.schemaId`; an unmatched cites target is refused at the
door. Found and fixed along the way: matches_schema_keys_fields must not
use schema_alias::matches_any's own "empty candidates matches everything"
shortcut (correct for the door/reachability call sites it mirrors from
C#) - v0's snapshot server calls matches() directly, without validate(),
so an empty-but-present schemas list must still match nothing there too.
- R-F: select() validates first and returns the typed refusal; matches()'s
hop guard is a real assert!, not a debug_assert! a release build would
compile out.
- R-G/P-1: Evaluation and SelectionPage both carry matched, the total match
count, not the page count.
- R-H: Cursor::compute_digest is length-prefixed for every string and list,
closing the collision Soul found (["a|b"] and ["a","b"] used to digest
the same).
- R-I: select_page projects a selection into the wire's SelectionPage -
headers or documents under Selection::projection, matched, and edges; a
header projection carries no payload, in edges too (S20).
Test fixture: real SHA-256-shaped schema ids (sha256:<hex>.v1) replace the
old fixture's schema names doubling as ids, which had silently masked the
R-E gap (vectors passed only because "leaf_a" happened to already be both
the alias and the id).
Two documented discrepancies against the map's own worked numbers, both
verified independently outside this crate (Rust bit decomposition plus a
Node/BigInt cross-check): an f32 of 3e20 is 300000006012263202816, not the
map's 300000002010536247296; 1e-7 as f64 is its own 79-digit exact
expansion, not "0.0000001" (a stale shortest-form assertion this fix batch
did not originally touch).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- cultnet.selection.schema.json: schemas/keys gain minItems: 1 (present
and empty is refused at the door, never "every schema/key" - omit the
field instead); limit documents that it is clamped 1..=200 by the
answering server rather than refused outside that range.
- cultnet_py/interop_peer.py: handle_server_message refused only an
exact ".v1" suffix, so a v2+ selection message fell through to a
silent empty-list answer instead of a loud refusal. is_unsupported_cultnet_version
matches any cultnet.*.vN with N > 0 instead. test_cultnet.py's R-2
test now covers v2 and v10, not only v1.
- mutate-dotnet.ps1: an entry's File (from -Entries, often written with
"/") is looked up against the $targets dictionary keyed by -Target's
own spelling; a "\\" vs "/" mismatch didn't throw at the lookup -
$targets[$file] silently returned $null - so the crash surfaced one
call later as a null-argument error inside a .NET path API instead of
as "unknown target". Both -Target and every File read from an entry
now normalise through Normalize-TargetPath before indexing $targets.
Discrepancy: the ruling names "both R-2 sites" for the Python fix; only
one site with this exact narrow-match bug exists in packages/cultnet-py
and packages/cultmesh-py (cultmesh_py/server.py's handle_message already
refuses every unrecognised schemaVersion, v1+ included, through its own
default branch). Fixed the one that exists; did not invent a second.
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>
R-2 was owed by Cut 1 and not yet done (docs/cultnet-selection-cut.md, section
9, Self's rulings 2026-09-22): a v0-only Python peer must refuse a
cultnet.selection.v1 carrier message loudly instead of silently answering
with an empty list.
- packages/cultnet-py/src/cultnet_py/interop_peer.py:
handle_server_message's fallback now recognises any cultnet.*.v1 schema
version and replies cultnet.error.v0 { code: "unsupported_schema_version",
details: { schemaVersion } } via the new unsupported_schema_version_error
helper, matching cultmesh_py's existing error shape. One test
(test_python_interop_peer_refuses_v1_selection_messages) proves a
snapshot_request.v1 and a database_subscribe.v1 message both get the
refusal, not an empty list.
- packages/cultmesh-py/src/cultmesh_py/server.py's delegating path
(_handle_connection_message -> handle_message) already refused every
unsupported schema version generically, v1 included - no code change
needed there. test_cultmesh_local_server_returns_errors_for_bad_requests
gains an explicit assertion for a snapshot_request.v1 message through that
same path, so the coverage is named rather than only implied by the
generic case.
This is the one exception to "do not touch packages/" in this fix batch: only
these two Python packages' interop/server files and their tests.
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>
1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its
own changelog says "Breaking" and the version number lied. Operator ruling:
semver is policy, strictly, for every published package family.
docs/semver-policy.md states what counts as breaking per surface (managed
API, Unity inspector attributes, CultCache wire format/store compatibility,
CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag
naming, and required changelog shape. Records the 1.0.60 scar as history;
does not renumber it.
scripts/check-changelog-semver.mjs is the mechanical check: refuses a
changelog "### Breaking" section under too small a bump, and refuses a
version that skips, reverses, or repeats the previous published tag. Skips
re-litigating a version that is already tagged (rebuilding immutable
history is not a release action). Wired into publish-packages.yml for the
four npm/PyPI packages, and into build-unity-package.ps1 (cultlib),
packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new
scripts/verify-caching-unity-release.ps1 (caching.unity has no build script
of its own to hook into).
18 node:test cases cover the required table (breaking+major/minor/patch,
non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution.
Hand-mutated each rule (bump classification, pre-1.0 lane requirement,
breaking-heading detection, breaking early-return, already-published skip)
and confirmed each kill, then restored byte-exact. No mutation harness is
committed; CultLib has none today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both comments around `STRICTER_THAN_CSHARP` called the divergences host
repairs. Most are not. Of the eleven, three carry a backslash and two have no
authority for `System.Uri` to accept; `refusedByCSharpUriShape` refuses those
five on the raw string, before there is a host to repair. Four are genuine host
repairs `URL` would make and C# would not — two trailing dots, two fullwidth
hosts — and the last two throw in `URL` before any rule of ours runs.
The same slip sits in `isProtectedEndpoint`'s own doc comment, over a different
eleven: it says `URL` repairs eleven spellings `System.Uri` refuses, when only
the four carrying a tab or CR/LF are rewritten and the other seven are admitted
as written.
Wording only; no rule changes. It matters because the next reader deciding
whether the pre-check earns its keep will reach for the reason the comment
gives, and "host repairs" points at the wrong half of the list.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two mutants the cut-2 pass recorded as equivalent were not. Both notes were
reasoning, and both were wrong in the same way: they argued from the shape of
the code instead of trying the input that breaks it.
The decoder's length refusal reads `payloadLength < 0 || expected !== length`.
The note said the first clause could never fire on its own, because a negative
payload length makes the sum smaller than the frame. It does not: the sum also
carries the three identity lengths, and a negative payload cancels against
them. A 37-byte frame declaring `channelLength=65535, payloadLength=-65535`
sums to exactly 37, matches its own byte length, and without the clause decodes
to three blank identities and an empty payload where `CultMeshRealtimeWireProtocol`
refuses it. Two smaller spellings do the same, `ch=1, pl=-1` over 37 bytes and
`ch=2, pl=-1` over 38. No test covered that shape; one does now, and the
mutation is listed and killed rather than excused.
The payload copy was worse, because the fix and the bug looked identical. The
decoder used `bytes.slice(...)`, which copies a `Uint8Array` — and the existing
test passes one, so it was green — but Node's `Buffer` overrides `slice` with
an alias of `subarray`. A `Buffer` is what a socket read hands a Node caller,
which is exactly how the QUIC plane in cut 4 will reach this function, so every
decoded payload would have been a live view of a frame buffer the transport is
free to reuse.
`Uint8Array.prototype.slice.call(bytes, ...)` was the obvious repair and does
copy, but `%TypedArray%.prototype.slice` takes its result constructor from
`@@species`, and `Buffer`'s is `FastBuffer`: the payload then comes back a
`Buffer` from a `Buffer` frame and a `Uint8Array` from a `Uint8Array` one, so
the same frame compares equal or unequal under `deepStrictEqual` depending on
how it arrived. `new Uint8Array(bytes.subarray(...))` copies and is one type on
both. Three mutations now guard the line — return a view, return a `Buffer`
view, return a species-typed copy — and the first two die only on their own
input type.
Mutation runner: 75 mutations, 75 killed, 0 survivors, three green controls.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One codec, `packages/cultmesh-ts/src/realtime-wire.ts`, rule for rule what
`CultMeshRealtimeWireProtocol` does: the 37-byte little-endian fixed header
with magic 0x31545343, the delivery byte as the C# enum ordinal, the i64
epoch and sequence, three u16 identity lengths, the i32 payload length, the
stamped header size and wire version, then UTF-8 identities and the payload.
It touches no socket; QUIC stream framing stays transport-owned. The blank
reading in `Validate` is `IsNullOrWhiteSpace`, whose whitespace set is not
`String.prototype.trim`'s, so it borrows `isNullOrWhiteSpaceCSharp` from
`cultnet-ts` rather than growing a second reading of the same rule.
Byte parity is proven in both directions and in two processes, which is what
the authority vectors cost us to learn. `contracts/cultmesh/realtime-frame-vectors.json`
is written by the new C# test under CULTMESH_WRITE_VECTORS=1 and decoded and
re-encoded here; `realtime-frame-vectors.ts-written.json` is written by
`scripts/write-cultmesh-realtime-frame-vectors.mjs` with this codec, over
different identities and epochs, and decoded by the same C# test with the
reference implementation alone. Both files share one field shape, so each
side reads both with one reader. The two cases that would put megabytes into
a committed contract file, identities at exactly 65,535 UTF-8 bytes and a
64 MiB payload, are recorded as a build rule plus the digest of the whole
encoding; both sides build them the same way.
Three things the C# reference gets from its type system and TypeScript does
not, each refused rather than left to surface as a stray runtime error: a
`bigint` outside the i64 range, a delivery string the union cannot spell, and
a leading U+FEFF, which `TextDecoder` deletes by default and
`Encoding.UTF8.GetString` keeps. The last one is a real divergence the test
caught; the decoder now sets `ignoreBOM`. U+FEFF is not whitespace to .NET,
so such an identity passes the transport contract and reaches the wire.
There was no C# test for this codec before; this is also its first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The unit separator that joins protocol ids sat in the source as a literal
U+001F: once in `cultmesh-authority.ts` and twice in its test. Every editor,
every diff and every review renders it as nothing, so `join("")` reads as a
join on the empty string and the transcript looks wrong to anyone checking it
against the C# reference, which spells the same separator as an escape.
The bytes were right, which is exactly why nothing caught it.
The three become escapes. The bytes do not move: the C#-written and the
TypeScript-written route vectors both still verify through the shared module,
and the sorted-transcript test still builds the same expected bytes.
A new test reads both files and refuses any byte below 0x20 that is not LF or
CR, so the class recurs loudly rather than silently. It earned its keep on the
first run by catching a literal U+001F in the comment introducing it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`isLoopbackEndpoint` hands its endpoint to `refusedByCSharpUriShape` before
`URL` sees it; `isProtectedEndpoint` did not, and `URL` repairs eleven
spellings `System.Uri` refuses outright. Every one of them answered protected
here and unprotected in the C# reference, which is the dangerous direction:
the channel-protection gate opened on a route `IsProtected` would have closed.
`wss:x`, `wss:host`, `wss:host:8443`, `wss:/host` and `wss:///host` have no
authority for `System.Uri`; `wss://host\path` and `wss://host:8443\` carry a
backslash; `wss://ho\tst/m`, `wss://host\t/m` and `quic://ho\tst` a tab, and
`wss://host\r\n/m` a CR/LF, all of which `URL` deletes.
Soul's 71 raw-shape spellings go in as a table with the answer the C#
`IsProtected` gave for each on 2026-09-16, and the test asserts the invariant
rather than equality: TypeScript may never call an endpoint protected that C#
refuses. Eleven of the 71 now differ the safe way round, C# protected and this
not, and they are pinned by name rather than excluded, so neither a twelfth
joining them nor one of them flipping passes quietly. Two throw in `URL` before
any rule of ours runs (an IPv6 zone id, a non-breaking-space host); the other
nine are host repairs `refusedByCSharpUriShape` refuses on purpose, because the
same repair would make `isLoopbackEndpoint` lie. None is dialable as a browser
CultMesh route.
The mutation that drops the pre-check from `isProtectedEndpoint` alone, leaving
`isLoopbackEndpoint`'s in place, is killed by that test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Soul replaced the shared `isUnsignedCertificate` call in the browser's
session-acceptance check with a local empty-string comparison and every one of
the 18 browser tests still passed, because every test used an empty signature.
A whitespace signature is where the two readings part: the shared route
verifier accepts such a route as unsigned on a loopback local-development
endpoint, and an inline comparison then demands a provider proof for the
session the verifier just let through. The test now opens that session with
three spaces, U+0085 and a no-break-space/tab/newline mix as well as the empty
string, and asserts U+FEFF the other way, since it is whitespace to JavaScript
and a signature to C#.
The mutation runner grew a second target. `shared` is the one verifier, killed
by the cultnet-ts tests; `browser` is `packages/cultmesh-browser/src/index.ts`,
killed by the browser tests. A browser mutation cannot be killed by cultnet-ts
tests, so the killer is the target's own workspace, and each target carries its
own sidecar, control, sentinel and post-restore dist assertion.
Two repairs this pass earned:
- The sidecar is now removed inside the `finally`, right after the verified
restore. A throw mid-run left it behind holding the pre-run bytes, and the
next run "repaired" the file from them, silently reverting everything fixed
in between. That happened here, twice, before it was noticed.
- The NUL in the loopback pre-check's strip regex had been written as a literal
byte rather than an escape. It read the same, but git classified the file as
binary and stopped normalising its line endings, so the whole file diffed as
rewritten. Escaped, along with the ASCII host range beside it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`isLoopbackEndpoint` handed the raw endpoint to `URL` and read the host it got
back. `URL` is a repairing parser and `System.Uri` is not, so seven spellings
were loopback here and remote in C#: a trailing dot on the host, which `URL`
drops; `ws:127.0.0.1`, `ws:/127.0.0.1` and `ws:///127.0.0.1`, whose authority
`URL` infers; a backslash path separator, which `URL` reads as a separator;
fullwidth digits and letters in the host, which `URL` folds to ASCII; and a tab
in the host, which `URL` deletes. Each of those is a local-development bypass:
a route the C# consumer would have demanded a certificate for opened unsigned
here.
`refusedByCSharpUriShape` refuses exactly those, on the raw string, before
`URL` sees it. Leading and trailing C0 controls and spaces around the whole
endpoint are stripped first, because both sides accept them. The spellings
Hands probed earlier and C# accepts -- `127.1`, hex, decimal,
`[::FFFF:7F00:1]`, userinfo, `localhost:8443` -- are untouched.
The doc comment claimed equivalence with `System.Uri.IsLoopback`. It is not
equivalent and now says so: two divergences remain, both unreachable from a
browser CultMesh route, and both named. An IPv6 zone id throws in `URL` and is
a host-local interface name no browser socket can dial; `file:` and `mailto:`
have no `URL` host and are refused by `openSocket`, which takes `ws:` and
`wss:` only.
The test is Soul's endpoint list with the C# answer as the expectation: 82 of
the 88 it probed, the six unreachable ones named in the doc comment instead.
Mutations: the trailing-dot refusal dropped, the scheme-and-authority shape
inverted, the backslash refusal dropped, and the non-ASCII host refusal
dropped -- each killed by the table.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The C# reference signs a transcript over a *constructed* route, and the
constructors clean on the way in: `CultMeshAuthorityRoute.Clean` trims each
protocol id, drops the blank ones, keeps one of each ordinally and sorts
ordinally; `RequireNonEmpty` trims the runtime id and endpoint; `Require`
trims the provider key id and coordinates; `CultMeshRouteCertificate` trims
the Odin key id; a blank generation becomes the runtime id and endpoint
joined by "@". TypeScript cleaned none of it and transcribed the caller's
string, so a route C# signed and accepted was refused here the moment any
field carried padding or a duplicate.
Soul's tamper loop is the evidence: C# accepted a duplicated protocol id, a
padded generation and a padded Odin key id against the committed vector's own
signature, and TypeScript refused all three. The test now reruns those, plus a
padded protocol id, a blank one, a reversed list, a padded endpoint and a
padded provider key id, against the C#-written vector's signature. A padded
Odin key id must also find its root, so the trust map keys on the trimmed id
as the C# dictionary does.
`verseId` is left alone on purpose: it is not a route field, and
`CanonicalRoute` passes the caller's string into `Canonical` untrimmed. The
test pins that too.
`protocolId ?? "cultmesh.documents.v1"` is gone. A C# route with no protocol
ids is `Array.Empty<string>()` and transcribes as the empty string; reading
the handshake's `protocolId` as a one-element list wrote bytes the reference
never writes. `canonicalSession` matches its C# counterpart the other way:
it cleans nothing on the request, because the handshake message is what the
wire carried, and only the endpoint is cleaned, because the one C# passes is
`Route.Endpoint`.
Mutations: the dedupe removed, the blank-id filter removed, the generation
trim removed, the provider key id trim removed, and the Odin key id trim
removed from the lookup -- each killed by its test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two edge alignments that have to land together, because the test for the
first asserts the messages of the second.
`String.prototype.trim` is not `char.IsWhiteSpace`. JavaScript counts U+FEFF,
which .NET does not; .NET counts U+0085, which JavaScript does not. Every
`trim()` in the shared verifier stood in for a C# `IsNullOrWhiteSpace` or
`Trim()`, so both were wrong at those two code points: a U+0085 signature was
an unsigned route to C# and a bad signature to TypeScript, and a U+FEFF
signature the other way round. `isCSharpWhiteSpace`, `trimCSharp` and
`isNullOrWhiteSpaceCSharp` are that set, and `isUnsignedCertificate` and the
signature trim now use it. The test pins all 34 code points Soul probed on
both sides against the answers .NET 10 gave on 2026-09-16.
The C# `Verify` also has three signature refusals, not one: a signature that
is not base64, a decoded length that is not 64, and a signature that decodes
but does not verify. TypeScript collapsed the first two into the third and
lost the distinction the reference draws. Split, with each message pinned by
its exact C# string. `verifyP256` keeps its own checks: it is the silent
`VerifySession` path, which returns false for all three.
Mutations: the whitespace-set site reverted to `String.prototype.trim`,
U+0085 dropped from the set, and the two new messages merged back into
"invalid" -- each killed by its test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The browser's local-development session-proof short-circuit tested
`!route.certificate`, while the shared verifier already treated an empty or
whitespace signature as unsigned, matching C# `Verify` (`:120`). A loopback
route carrying a blank-signature certificate therefore passed the verifier
and was then refused for lacking a provider key, where C# returns
`IsLocalDevelopment` and skips the session proof.
`isUnsignedCertificate` in `cultnet-ts/authority` is now the one owner of
that reading; the verifier and the browser both call it. A browser test pins
the local-development loopback open without a provider proof, the signed
loopback certificate still demanding it, and the remote counterpart refused
in both trust modes. The mutation runner gains two predicate mutations
(whitespace treated as signed; only a missing certificate treated as
unsigned), both killed by the existing whitespace-signature test in
cultnet-ts.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`contracts/cultmesh/authority-route-vectors.json` proves C# bytes verify
in TypeScript. Nothing in the repo proved the reverse; Soul's probe did it
once in a scratch directory. It is now a committed check in the same shape,
mirrored:
- `scripts/sign-cultmesh-authority-vectors.mjs` builds the route and
session transcripts with the shared module, signs them with fixed
TypeScript-owned test keys over awkward inputs (a quic scheme with a
query, three unsorted protocol ids, non-ASCII generation text, priority
13), self-verifies, and writes
`contracts/cultmesh/authority-route-vectors.ts-signed.json`.
- `CultMeshAuthorityProofTests.AuthorityRouteVectorsSignedByTypeScriptVerifyHere`
reads that file with keys taken from the file, asserts the protocol ids
are unsorted, that `Validate` and `VerifySessionProof` accept, and that
a priority-tampered route and a replayed nonce are refused.
- The TypeScript vector test runs over both files, so a transcript change
on the TypeScript side fails here before C# could trust a stale file.
Checked negatively: one base64 character changed in the committed
signature fails the C# test.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
verifyP256 handed WebCrypto `view.slice().buffer`. For a Node Buffer,
`slice` is `subarray`, so `.buffer` is the shared pool: a pooled Buffer or
a subarray of a larger allocation verified false while the same bytes as a
plain Uint8Array verified true. Copy exactly the viewed bytes.
Trust rules that had drifted from `CultMeshAuthorityProof.cs`:
- A certificate whose signature is empty or whitespace is an unsigned
route (C# `Verify` line 120): local-development loopback accepts it,
everything else refuses with the unsigned message.
- Loopback is `System.Uri.IsLoopback`: any 127.0.0.0/8 host, `::1`, the
IPv4-mapped `::ffff:127.0.0.1` (only `.1`), and the host names
`localhost` and `loopback`. WHATWG URL normalises `127.1`, `0x7f000001`
and `2130706433` the way System.Uri does. Probed on the C# side through
the public policy before the rule was written; the test pins that set.
- The root lookup runs before the validity window, so an expired route
under an unknown root is refused as untrusted on both sides.
- A policy listing one Odin key id twice is refused at first use, the
closest a plain object gets to the C# constructor throwing.
One test per rule, one mutation per rule in the runner's list. A fixture
whose protocol ids are unsorted and whose expected transcript is the
sorted one means removing the sort is now killed.
The runner now matches anchors in the file's own line ending. On an
autocrlf checkout the two multi-line anchors it already had ("route
transcript field order", "session transcript binds the nonce") matched
nothing, so the run threw at those entries instead of testing them.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>