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 ate5348d3): - validate refuses an empty-but-present schemas/keys list (already had this) and now also refuses any individual entry that is empty or whitespace (fields[i]-style names: schemas[i]/keys[i]). - The evaluator never sees []; matches_schema_keys_fields does not special-case an empty list as "no filter" (it never did here - that C# fallback, CultNetSelectionEvaluator.cs:84 pre-fix, was never mirrored into Rust). - snapshot_query.rs's v0 lowering gets its own cleaning, owned by the lowering and not the evaluator: lower_v0_list turns an empty or all-blank v0 schemaIds/recordKeys list into None before it ever reaches validate or select. Tests: cultnet-rs 173 passed, 0 failed (was 111 baseline #[test]s pre-cut; +62 across lib unit tests, tests/selection.rs and tests/cultnet.rs). cultcache-rs unmodified, 64 passed, 0 failed (untouched by this commit - confirmed unaffected). GameCult.Networking.Tests (the new SelectionParityVectorTests.cs file, compiled but not yet fully exercised - see below) builds clean. Mutation harness (tools/eureka-mutations.ps1, scripts/mutate-cultnet- selection-rust.entries.ps1): 22 entries against src/selection.rs and src/snapshot_query.rs, all KILLED, M0 green. Covers: S1's empty-list and new blank-entry refusals (schemas and keys, revert x4), order/descending (revert), cursor staleness (revert + loosening) and digest (revert), S7's incoming negation (revert + loosening), S16's le/ge boundaries (revert x2), S18's reference_outside_target (revert), Q-J's door regex and -0 exclusion (revert x2), Q-J's comparator integer-length and sign (revert x2), Q-J's integer-through-float64 renderer, decimal trailing-zero stripping and exponent expansion (revert x3, the last two pinned via direct canonical_number unit tests since render_f64 itself cannot reach exponent notation given Rust's Display contract - verified, not assumed), and the new v0-lowering rule (revert). Negative grep: `rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src` empty. Not included here, landing as commit 4 once ready: the committed parity vector files (contracts/cultnet/interop/selection-vectors.{cs,rs}-written.json). Both directions already work for six evaluated selections and the [] door-refusal case; the blank-key-entry refusal vector cannot be generated from the C# side yet because the parallel C# fix batch implementing that door check (docs/cultnet-selection-cut.md, "Self's rulings...for the commit 2 fix batch") has not landed on this branch as of this commit. Rust's own tests (validation_refuses_a_blank_entry_in_schemas_or_keys, serve_read_only_raw_snapshot_lowers_an_empty_or_blank_v0_schema_list_to_no_filter) already pin both new rules independently of C#. Structural delta: packages/cultnet-rs/src/selection.rs +1407 (new), contracts.rs +616/-21, snapshot_query.rs +76/-21 (net +55, collapsing the fifth selector engine into one call), lib.rs +2, tests/cultnet.rs +67 (new v0-lowering test), tests/selection.rs +900 (new, evaluator tests + vector machinery), scripts/mutate-cultnet-selection-rust.entries.ps1 +233 (new), tests/GameCult.Networking.Tests/SelectionParityVectorTests.cs +296 (new). Against section 14's ~430-line estimate for selection.rs+contracts.rs: the two together land at ~2000 lines added, well over estimate - the map's own §14 correction already flagged commits 0/1 running roughly 2x estimate for the same reason (the hop's incoming index, the cursor, and per-field door messages cost more than the prose assumed), and the v1 raw-message handling (SelectionDocumentRecord/RawDocumentHeader/Edge require_*/encode_* helpers, ~400 lines) was not separately budgeted at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
e5348d3d57
commit
b090ca97f9
8 changed files with 3559 additions and 21 deletions
233
scripts/mutate-cultnet-selection-rust.entries.ps1
Normal file
233
scripts/mutate-cultnet-selection-rust.entries.ps1
Normal 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) => {'
|
||||
}
|
||||
)
|
||||
Loading…
Reference in a new issue