CultNet selection cut1, commit 3: the Rust runtime

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>
This commit is contained in:
Meta Vens 2026-09-22 05:31:19 +02:00
commit b090ca97f9
8 changed files with 3559 additions and 21 deletions

View file

@ -0,0 +1,233 @@
# Entries for tools/eureka-mutations.ps1 (C:\Users\Meta\.claude\skills\eureka\tools):
# CultNet typed selection, Cut 1 commit 3 - the Rust evaluator
# (src/selection.rs), mirroring the reference's rules at wire parity
# (docs/cultnet-selection-cut.md section 6/7/10). Target: src/selection.rs.
#
# Two commands are in play (the harness runs M0 once per distinct Command in the suite):
# `cargo test --test selection` for the evaluator-level rules (the integration test
# binary in packages/cultnet-rs/tests/selection.rs), and `cargo test --lib` for the
# canonical_number module's own unit tests (its render_*/canonicalize_decimal_digits functions are
# not otherwise exercised by the integration suite - Row::number already returns pre-rendered
# strings there). Run with CARGO_TARGET_DIR set to this worktree's own target directory per the
# campaign's build rules.
#
# Not exhaustive of every rule selection.rs carries (S1-S23's Rust-side coverage is
# "select_over_a_toy_row_set_matches_orders_hops_pages_and_refuses" plus these named mutants, per
# the map's S14): order, cursor freshness and digest, the one negation, the numeric boundary, the
# out-of-target refusal, and Q-J's door/comparator/renderer - the rules a Rust caller (Huginn)
# depends on that the reference's own suite already pins on the C# side.
@(
@{
Id = 'RS-S3-Revert'
Rule = 'S3: order reverses under descending.'
Test = 'orders_by_ordinal_then_identity_and_reverses'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'if descending { ordering.reverse() } else { ordering }'
New = 'let _ = descending; ordering'
},
@{
Id = 'RS-CursorStale-Revert'
Rule = 'A cursor from a moved asOf is refused cursor_stale.'
Test = 'pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'if cursor.as_of != as_of {'
New = 'if false {'
},
@{
Id = 'RS-CursorStale-Loosening'
Rule = 'Staleness compares equality, not only "cursor is newer than the current asOf".'
Test = 'pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'if cursor.as_of != as_of {'
New = 'if cursor.as_of > as_of {'
},
@{
Id = 'RS-CursorDigest-Revert'
Rule = 'A cursor whose digest does not match this selection is refused cursor_invalid.'
Test = 'pages_exactly_once_and_refuses_a_stale_or_mismatched_cursor'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'if cursor.digest != Cursor::compute_digest(selection) {'
New = 'if false {'
},
@{
Id = 'RS-S7-Revert'
Rule = 'S7: cited { exists } is honoured, not ignored.'
Test = 'cited_exists_is_the_one_negation'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'candidates.retain(|row| incoming.contains(row.record_key()) == exists);'
New = 'candidates.retain(|row| incoming.contains(row.record_key()) || true);'
},
@{
Id = 'RS-S7-Loosening'
Rule = 'exists:true and exists:false must answer opposite sets, not the same test inverted twice.'
Test = 'cited_exists_is_the_one_negation'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'candidates.retain(|row| incoming.contains(row.record_key()) == exists);'
New = 'candidates.retain(|row| incoming.contains(row.record_key()) != exists);'
},
@{
Id = 'RS-S16-Le-Revert'
Rule = 'S16: le includes the equal boundary.'
Test = 'compares_numbers_at_the_boundary_for_all_four_operators'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'SelectionOperator::Le => ordering != Ordering::Greater,'
New = 'SelectionOperator::Le => ordering == Ordering::Less,'
},
@{
Id = 'RS-S16-Ge-Revert'
Rule = 'S16: ge includes the equal boundary.'
Test = 'compares_numbers_at_the_boundary_for_all_four_operators'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'SelectionOperator::Ge => ordering != Ordering::Less,'
New = 'SelectionOperator::Ge => ordering == Ordering::Greater,'
},
@{
Id = 'RS-S18-Revert'
Rule = "D9/S18: an edge naming a row outside its reference's declared target refuses the selection."
Test = 'refuses_an_edge_outside_its_declared_target'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'if leaves.is_empty() || leaves.iter().any(|leaf| leaf == to.schema_id()) {
return Ok(());
}'
New = 'if true {
return Ok(());
}'
},
@{
Id = 'RS-QJ-Door-Regex-Revert'
Rule = 'Q-J: the door refuses a non-canonical number spelling rather than accepting it.'
Test = 'validation_refuses_non_canonical_number_spellings'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'Some(number) if !canonical_number::is_canonical(number) => {'
New = 'Some(number) if false && !canonical_number::is_canonical(number) => {'
},
@{
Id = 'RS-QJ-Door-NegativeZero-Revert'
Rule = 'Q-J: "-0" is excluded even though the digit grammar alone matches it.'
Test = 'validation_refuses_non_canonical_number_spellings'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'if value == "-0" {
return false;
}'
New = ''
},
@{
Id = 'RS-QJ-Compare-IntegerLength-Revert'
Rule = "Q-J: the comparator checks the integer part's length before its digits."
Test = 'canonical_number_compares_by_magnitude'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'let magnitude = if integer_left.len() != integer_right.len() {
integer_left.len().cmp(&integer_right.len())
} else {'
New = 'let magnitude = if false {
integer_left.len().cmp(&integer_right.len())
} else {'
},
@{
Id = 'RS-QJ-Compare-Sign-Revert'
Rule = 'Q-J: the comparator orders a negative value below a positive one.'
Test = 'canonical_number_compares_by_sign'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'if negative_left != negative_right {'
New = 'if false {'
},
@{
Id = 'RS-QJ-RenderInteger-Float64-Revert'
Rule = 'Q-J: an i64 renders its exact decimal, never through f64 (2^53 boundary).'
Test = 'selection::canonical_number::tests::render_i64_past_2_pow_53_is_exact'
Command = 'cargo test --lib'
File = 'src/selection.rs'
Old = 'pub fn render_i64(value: i64) -> String {
canonicalize_decimal_digits(&value.to_string())
}'
New = 'pub fn render_i64(value: i64) -> String {
canonicalize_decimal_digits(&(value as f64).to_string())
}'
},
@{
Id = 'RS-QJ-DecimalTrailingZeros-Revert'
Rule = 'Q-J: canonicalize_decimal_digits strips trailing fractional zeros.'
Test = 'selection::canonical_number::tests::canonicalize_decimal_digits_strips_leading_and_trailing_zeros'
Command = 'cargo test --lib'
File = 'src/selection.rs'
Old = "let fraction_part = fraction_part.trim_end_matches('0');"
New = 'let fraction_part = fraction_part;'
},
@{
# Not killed through render_f64/render_f32: probe_f64_display_never_uses_exponent_notation
# (this module's own tests) established that Rust's f64/f32 Display never produces exponent
# notation, so no real render_f64 input reaches the branch this reverts - unlike C#'s
# ToString(), which does. Killed directly against expand_scientific_notation instead, which
# pins the algorithm on hand-built exponent strings regardless of whether Display ever
# supplies one today.
Id = 'RS-QJ-ExponentExpansion-Revert'
Rule = 'Q-J: a string in exponent notation is rewritten to positional digits.'
Test = 'selection::canonical_number::tests::expand_scientific_notation_rewrites_hand_built_exponent_forms'
Command = 'cargo test --lib'
File = 'src/selection.rs'
Old = "if value.find(['e', 'E']).is_none() {
return value.to_string();
}"
New = 'return value.to_string();'
},
@{
Id = 'RS-EmptySchemas-Revert'
Rule = 'S1: an empty (but present) schemas list is refused, never answered as an empty page.'
Test = 'validation_refuses_undeclared_index_role_and_empty_lists'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'if matches!(&selection.schemas, Some(schemas) if schemas.is_empty()) {'
New = 'if false {'
},
@{
Id = 'RS-EmptyKeys-Revert'
Rule = 'S1: an empty (but present) keys list is refused, never answered as an empty page.'
Test = 'validation_refuses_undeclared_index_role_and_empty_lists'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'if matches!(&selection.keys, Some(keys) if keys.is_empty()) {'
New = 'if false {'
},
# Self's ruling, 2026-09-22 (docs/cultnet-selection-cut.md, commit 2 fix batch).
@{
Id = 'RS-BlankSchema-Revert'
Rule = 'The door refuses a blank entry inside a present schemas list, not only an empty list.'
Test = 'validation_refuses_a_blank_entry_in_schemas_or_keys'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'if schema.trim().is_empty() {'
New = 'if false {'
},
@{
Id = 'RS-BlankKey-Revert'
Rule = 'The door refuses a blank entry inside a present keys list.'
Test = 'validation_refuses_a_blank_entry_in_schemas_or_keys'
Command = 'cargo test --test selection'
File = 'src/selection.rs'
Old = 'if key.trim().is_empty() {'
New = 'if false {'
},
@{
Id = 'RS-V0Lowering-Revert'
Rule = 'v0''s own cleaning: an empty or all-blank v0 list lowers to null (no filter), not to a list the door or the evaluator would have to refuse or misread.'
Test = 'serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter'
Command = 'cargo test --test cultnet'
File = 'src/snapshot_query.rs'
Old = 'Some(values) if !values.is_empty() && values.iter().any(|value| !value.trim().is_empty()) => {'
New = 'Some(values) => {'
}
)