Skip to content

Domain-specific mutation operators via universalmutator (complementing cargo-mutants) #141

Description

@Troublor

Motivation

Our mutation-testing gate uses cargo-mutants, which ships a fixed, curated set of structural operators (replace function body with a default, flip binary/relational/logical/bit operators, a few literals). It has no plugin API, so it is structurally blind to the mutations that matter most for a state-commitment system — endianness, field-element chunk widths, codec configuration, canonicalization, and the trie's level/bit-width arithmetic.

universalmutator is regex-rule based (regex ==> replacement), language-agnostic, and auto-discards mutants that don't compile or compile identically. That makes it a good vehicle for a complementary, domain-specific mutation layer: cargo-mutants guards generic code structure; universalmutator guards SALT semantics.

This issue catalogs candidate domain operators to prototype. The selection filter throughout: "what soundness or determinism invariant would a surviving mutant here violate?" We deliberately skip operators that overlap with cargo-mutants (generic arithmetic/relational/logical), and skip the resize load-factor / growth-multiplier (configurable by design, not consensus-critical).

A. Trie-structure operators (highest leverage)

The SALT trie's correctness reduces to "right level-base and right bit-width?" — pure integer arithmetic over a STARTING_NODE_ID[level] table and three distinct bit-widths (TRIE_WIDTH_BITS = 8, MIN_BUCKET_SIZE_BITS = 8, BUCKET_SLOT_BITS = 40). A wrong constant produces a plausible, compiling, wrong structure — exactly what mutation testing exists to catch. See salt/src/trie/node_utils.rs and salt/src/constant.rs.

A1. Level-index off-by-one (the base table) — the sharpest family:

STARTING_NODE_ID\[level \+ 1\] ==> STARTING_NODE_ID[level]
STARTING_NODE_ID\[level - 1\]  ==> STARTING_NODE_ID[level]
MAIN_TRIE_LEVELS - 1           ==> MAIN_TRIE_LEVELS
MAX_SUBTREE_LEVELS - 1         ==> MAX_SUBTREE_LEVELS - 2

Expected kills: test_get_child_node, test_get_parent_node (inverse relationship), test_bucket_root_node_id, test_subtree_leaf_for_key.

A2. Bit-width constant confusion (the 24/40/8 split) — swap one named width for another real one; invisible to any generic tool:

<< BUCKET_SLOT_BITS      ==> << BUCKET_ID_BITS
>> MIN_BUCKET_SIZE_BITS   ==> >> BUCKET_SLOT_BITS
<< BUCKET_SLOT_BITS       ==> << (BUCKET_SLOT_BITS - 1)

Expected kills: test_subtree_leaf_for_key, test_vc_position_in_parent (max-bucket case).

A3. Fanout shift direction / amount (the << 8 / >> 8 that is the 256-way branching):

\(parent_relative_position << TRIE_WIDTH_BITS\) ==> (parent_relative_position >> TRIE_WIDTH_BITS)
relative_position >> TRIE_WIDTH_BITS            ==> relative_position << TRIE_WIDTH_BITS
<< TRIE_WIDTH_BITS                              ==> << (TRIE_WIDTH_BITS + 1)

Expected kills: get_child_node/get_parent_node inverse test.

A4. Segment granularity (256 slots per leaf):

>> MIN_BUCKET_SIZE_BITS ==> >> (MIN_BUCKET_SIZE_BITS + 1)
% TRIE_WIDTH            ==> % (TRIE_WIDTH - 1)

Expected kills: test_subtree_leaf_for_key_segment_boundaries, test_subtree_leaf_start_key, test_vc_position_in_parent.

A5. Ceil-division rounding in the subtree-root climbsubtree_root_level does (capacity + MIN_BUCKET_SIZE - 1) >> BITS; dropping the - 1 turns ceil into floor, wrong at exact powers of 256. The subtlest one, and a perfect target because the rounding correction is invisible to the eye:

\(capacity \+ MIN_BUCKET_SIZE as u64 - 1\) ==> (capacity + MIN_BUCKET_SIZE as u64)
capacity > MIN_BUCKET_SIZE as u64          ==> capacity >= MIN_BUCKET_SIZE as u64
level -= 1                                  ==> level += 1

Expected kills: test_subtree_root_level (257→3, 65536→3, 65537→2 boundary cases).

Validation loop: get_child_node/get_parent_node are tested as exact inverses over a parent × child-index matrix, so most A1–A4 mutants should die there. Any survivor points to a coverage gap in that inverse test.

B. Endianness / byte-order

Explicit byte packing in the hasher and codecs; wrong endianness silently breaks determinism and cross-platform reproducibility, and no generic operator flips it.

to_le_bytes   ==> to_be_bytes
to_be_bytes   ==> to_le_bytes
from_le_bytes ==> from_be_bytes
from_be_bytes ==> from_le_bytes

Expected kills: salt/src/state/hasher.rs pinned-output tests, salt/tests/proof_fixture_codec.rs.

C. Field-element / chunk width (the 31-vs-32 hazard)

Classic commitment bug: a value is 32 bytes but a field element only safely holds 31. Scope the bare 31/32 rules to specific files.

\.chunks\(32\)  ==> .chunks(31)
\.chunks\(31\)  ==> .chunks(32)
>> 8            ==> >> 4
& 0xff          ==> & 0x7f

Expected kills: trie chunking / scalar-encoding tests.

D. Serialization codec config

The legacy() codec choice is the wire format; swapping it should break the byte-for-byte fixture test.

\.legacy\(\)         ==> .standard()
with_fixint_encoding ==> with_varint_encoding
with_little_endian   ==> with_big_endian

Expected kills: salt/tests/proof_fixture_codec.rs.

E. Canonicalization removal (sort / dedup)

The proof path sorts/dedups to reach canonical form. Deleting those calls is a soundness mutation (duplicates / unordered elements should be rejected or change the root). Line deletion is not in cargo-mutants' repertoire.

^(\s*).*\.sort(_unstable)?\(\);        ==> \1
^(\s*).*\.dedup(_by)?\([^)]*\);        ==> \1
sort_by\(([^)]*)a\.cmp\(&?b\)([^)]*)\) ==> sort_by(\1b.cmp(&a)\2)

Expected kills: salt/src/proof/prover.rs sort/dedup canonicalization tests.

F. Hash-diffusion ops

Rotates / xors in hash mixing, which cargo-mutants won't touch. Keep xor→or file-scoped to the hasher.

rotate_left   ==> rotate_right
rotate_right  ==> rotate_left
wrapping_add  ==> wrapping_sub
\^             ==> |

Expected kills: salt/src/state/ahash/fallback.rs, salt/src/state/hasher.rs pinned-hash tests.

Explicitly out of scope

  • Generic arithmetic / relational / logical / function-body operators — already covered by the cargo-mutants gate; duplicating them just muddies which tool is authoritative.
  • Bucket resize load-factor (BUCKET_RESIZE_LOAD_FACTOR_PCT) and growth multiplier (BUCKET_RESIZE_MULTIPLIER) — configurable by design, not consensus-critical.

Proposed next steps

  • Consolidate families A–F into a single file-scoped mutants/salt.rules (Rust isn't a first-class universalmutator language, so run with the generic ruleset + these custom rules).
  • Run universalmutator over salt/src/trie/, salt/src/constant.rs, salt/src/state/, and salt/src/proof/; diff the survivor set.
  • Triage survivors: each is either a missing test (add coverage) or an equivalent mutant (record with justification, mirroring the existing cargo-mutants suppression registry).
  • Decide whether this runs as an occasional/manual analysis or graduates into a scheduled CI job alongside the sharded full cargo-mutants run.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions