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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions