-
Notifications
You must be signed in to change notification settings - Fork 7
feat(mutation): domain-specific operator packs via universalmutator (#141) #143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
vincent-k2026
wants to merge
8
commits into
main
Choose a base branch
from
krabat/feat/spec-mutation-operators
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
2a85c5d
feat(mutation): domain-specific operator packs via universalmutator
vincent-k2026 53594c8
fix(mutation): let mutate invocation errors fail the spec runner
vincent-k2026 1c88891
fix(spec-mutation): harden operator rules and mutant classification
8a879c9
feat(spec-mutation): switch to comby operators, fold into mutation.yml
58f305b
refactor(spec-mutation): classify unviable mutants, trim redundancy
5d51f0f
ci(spec-mutation): keep mutant analysis lint-tolerant (flyq #7)
d4bf1e8
fix(spec-mutation): address automated re-review of the comby migration
b04648e
docs(spec-mutation): drop dead `mode` key, fix suppression triage not…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| name: Spec Mutation | ||
|
|
||
| on: | ||
| workflow_dispatch: | ||
| inputs: | ||
| packs: | ||
| description: "Rule packs to run (space-separated: trie canon endian)" | ||
| required: false | ||
| default: "trie canon endian" | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| spec-mutation: | ||
| name: universalmutator packs | ||
| runs-on: ubuntu-24.04 | ||
| timeout-minutes: 330 | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - name: Install Rust | ||
| uses: actions-rust-lang/setup-rust-toolchain@v1 | ||
|
vincent-k2026 marked this conversation as resolved.
Outdated
|
||
|
|
||
| - name: Install cargo-nextest | ||
| uses: taiki-e/install-action@nextest | ||
|
|
||
| - name: Install universalmutator | ||
| run: pip install universalmutator==1.14.1 | ||
|
vincent-k2026 marked this conversation as resolved.
Outdated
|
||
|
|
||
| - name: Warm test binaries | ||
| run: cargo nextest run -p salt --no-default-features --features parallel --no-run | ||
|
|
||
| - name: Run spec mutation | ||
| env: | ||
| PACKS: ${{ inputs.packs }} | ||
| run: scripts/spec_mutation.sh $PACKS | ||
|
|
||
| - name: Publish step summary | ||
| if: always() | ||
| run: | | ||
| if [ -f target/spec-mutants/report.md ]; then | ||
| cat target/spec-mutants/report.md >> "$GITHUB_STEP_SUMMARY" | ||
| fi | ||
|
|
||
| - name: Upload results | ||
| if: always() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: spec-mutation-results | ||
| path: | | ||
| target/spec-mutants/report.md | ||
| target/spec-mutants/survivors.txt | ||
| target/spec-mutants/manifest.txt | ||
| if-no-files-found: ignore | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # Domain-specific mutation operators | ||
|
|
||
| Regex mutation packs run through | ||
| [universalmutator](https://github.com/agroce/universalmutator), complementing | ||
| the `cargo-mutants` gate (issue #141). `cargo-mutants` ships a fixed set of | ||
| structural operators and has no plugin API, so it never generates the | ||
| mutations that matter most for a state-commitment system. Each pack targets a | ||
| specific soundness or determinism invariant; each rules file documents its | ||
| family. The selection filter for adding rules: *"what soundness or | ||
| determinism invariant would a surviving mutant here violate?"* Generic | ||
| arithmetic/relational/logical operators are deliberately excluded — the | ||
| `cargo-mutants` gate already owns those. | ||
|
|
||
| | Pack | Rules | Scope | Invariant guarded | | ||
| | --- | --- | --- | --- | | ||
| | `trie` | `trie.rules` | `trie/node_utils.rs`, `trie/trie.rs`, `constant.rs`, `types.rs`, `proof/shape.rs` | Level-base and bit-width arithmetic of the two-tier trie (`STARTING_NODE_ID`, 8/24/40 splits, 256-way fanout, ceil rounding) | | ||
| | `canon` | `canon.rules` | `proof/prover.rs`, `proof/salt_witness.rs` | Canonical form: sort/dedup on the proof path must not be removable or reversible | | ||
| | `endian` | `endian.rules` | `state/ahash/convert.rs`, `state/hasher.rs`, `types.rs` | Byte-order determinism of the consensus hash and 12-byte metadata codec (see issue #127) | | ||
|
|
||
| ## Running | ||
|
|
||
| ```bash | ||
| pip install universalmutator | ||
|
|
||
| # Everything (generation + kill analysis; needs cargo + nextest): | ||
| scripts/spec_mutation.sh | ||
|
|
||
| # One pack, or generation only (no Rust toolchain needed): | ||
| scripts/spec_mutation.sh trie | ||
| GEN_ONLY=1 scripts/spec_mutation.sh canon endian | ||
| ``` | ||
|
|
||
| In CI: the `Spec Mutation` workflow (`workflow_dispatch`) runs the packs and | ||
| uploads `report.md` + `survivors.txt`. Expect roughly 60 mutants for all | ||
| three packs and a few hours of runtime (each mutant rebuilds `salt` and runs | ||
| the suite; killed mutants stop at the first failing test). | ||
|
|
||
| Mutants are generated only for production lines (everything before the first | ||
| top-level `#[cfg(test)]`), so they never land in test code. | ||
|
|
||
| ## Triage policy | ||
|
|
||
| Every **survivor** is either: | ||
|
|
||
| 1. a missing test — add a killing test (preferred), or | ||
| 2. an equivalent mutant — document the justification in the rules file next | ||
| to the rule (and consider tightening the rule's regex). | ||
|
|
||
| **Invalid** (non-compiling) mutants are fine in moderation; if a rule mostly | ||
| produces invalid mutants, tighten its pattern. | ||
|
|
||
| ## Known gaps | ||
|
|
||
| - Multi-line `sort_unstable_by!(...)` macro invocations (e.g. in | ||
| `trie/trie.rs`) are not matched by the line-based deletion rules. | ||
| - The `endian` pack finds nothing in `convert.rs` until the explicit | ||
| little-endian reads land (PR #142); after that it guards them. | ||
| - Families C (field-element chunk widths), D (serialization codec config) | ||
| and F (hash diffusion ops) from issue #141 are staged for a follow-up | ||
| after the first survivor triage of A/B/E. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Family E - canonicalization removal (issue #141). | ||
| # | ||
| # The proof path sorts and deduplicates to reach canonical form. Deleting | ||
| # those calls (or flipping comparator direction) is a soundness mutation: | ||
| # duplicates or unordered elements should be rejected or change the root. | ||
| # Statement deletion is not in cargo-mutants' repertoire. | ||
| # | ||
| # Known gap: multi-line sort_unstable_by!/by_key! macro invocations are not | ||
| # matched by these line-based rules (e.g. trie.rs update_leaf_nodes). | ||
|
|
||
| # Delete single-line sort/dedup statements, keeping the indentation. | ||
| ^(\s*).*\.sort\(\); ==> \1 | ||
| ^(\s*).*\.sort_unstable\(\); ==> \1 | ||
| ^(\s*).*\.sort_unstable_by\(.*\); ==> \1 | ||
| ^(\s*).*\.sort_unstable_by_key\(.*\); ==> \1 | ||
| ^(\s*).*\.dedup\(\); ==> \1 | ||
| ^(\s*).*\.dedup_by\(.*\); ==> \1 | ||
| ^(\s*)sort_unstable!\([A-Za-z_0-9]+\); ==> \1 | ||
|
|
||
| # Flip comparator direction inside sort closures. | ||
| b\.cmp\(a\) ==> a.cmp(b) | ||
| a\.cmp\(b\) ==> b.cmp(a) | ||
| a\.cmp\(&b\) ==> b.cmp(&a) | ||
|
vincent-k2026 marked this conversation as resolved.
Outdated
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| # Family B - endianness / byte-order (issue #141). | ||
| # | ||
| # Explicit byte packing in the hasher and codecs; flipping endianness | ||
| # silently breaks determinism and cross-platform reproducibility (see | ||
| # issue #127), 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 | ||
|
vincent-k2026 marked this conversation as resolved.
Outdated
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| # Family A - trie-structure operators (issue #141). | ||
| # | ||
| # The SALT trie's correctness reduces to "right level base and right | ||
| # bit-width": pure integer arithmetic over the STARTING_NODE_ID table and | ||
| # the 8/24/40 bit-width split. A wrong constant produces a plausible, | ||
| # compiling, wrong structure that generic operators never generate. | ||
| # universalmutator rule format: <python-regex> ==> <replacement>. | ||
|
|
||
| # A1. Level-index off-by-one in the STARTING_NODE_ID base table. | ||
| STARTING_NODE_ID\[([a-z_\.]+) \+ 1\] ==> STARTING_NODE_ID[\1] | ||
| STARTING_NODE_ID\[([a-z_\.]+) - 1\] ==> STARTING_NODE_ID[\1] | ||
| MAIN_TRIE_LEVELS - 1 ==> MAIN_TRIE_LEVELS | ||
| MAX_SUBTREE_LEVELS - 1 ==> MAX_SUBTREE_LEVELS - 2 | ||
|
|
||
| # A2. Bit-width constant confusion (swap one named width for another real one). | ||
| << BUCKET_SLOT_BITS ==> << BUCKET_ID_BITS | ||
|
vincent-k2026 marked this conversation as resolved.
Outdated
|
||
| >> MIN_BUCKET_SIZE_BITS ==> >> BUCKET_SLOT_BITS | ||
| << BUCKET_SLOT_BITS ==> << (BUCKET_SLOT_BITS - 1) | ||
|
|
||
| # A3. Fanout shift direction / amount (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) | ||
|
|
||
| # A4. Segment granularity (256 slots per leaf). | ||
| >> MIN_BUCKET_SIZE_BITS ==> >> (MIN_BUCKET_SIZE_BITS + 1) | ||
| % TRIE_WIDTH ==> % (TRIE_WIDTH - 1) | ||
|
|
||
| # A5. Ceil-division rounding in the subtree-root climb (subtree_root_level). | ||
|
vincent-k2026 marked this conversation as resolved.
Outdated
|
||
| \(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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| #!/usr/bin/env bash | ||
| # Domain-specific mutation testing via universalmutator (issue #141). | ||
| # | ||
| # cargo-mutants guards generic code structure; these regex packs guard SALT | ||
| # semantics (trie arithmetic, canonicalization, endianness). See | ||
| # mutants/operators/README.md for the pack catalog and triage policy. | ||
| # | ||
| # usage: spec_mutation.sh [pack ...] default: trie canon endian | ||
| # | ||
| # Environment overrides: | ||
| # OUT_DIR results directory, default target/spec-mutants | ||
| # GEN_ONLY 1 = only generate mutants, skip the cargo analysis loop | ||
| # CARGO_ARGS cargo package/feature selection for check + nextest | ||
| set -euo pipefail | ||
|
|
||
| ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" | ||
| OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/spec-mutants}" | ||
| GEN_ONLY="${GEN_ONLY:-0}" | ||
| CARGO_ARGS="${CARGO_ARGS:--p salt --no-default-features --features parallel}" | ||
|
|
||
| cd "$ROOT_DIR" | ||
|
|
||
| # Pack -> production source files it applies to. Scoping matters: applying, | ||
| # say, the endian pack to trie code would only generate noise. | ||
| targets_for() { | ||
| case "$1" in | ||
| trie) | ||
| echo "salt/src/trie/node_utils.rs salt/src/trie/trie.rs salt/src/constant.rs salt/src/types.rs salt/src/proof/shape.rs" | ||
| ;; | ||
| canon) | ||
| echo "salt/src/proof/prover.rs salt/src/proof/salt_witness.rs" | ||
| ;; | ||
| endian) | ||
| echo "salt/src/state/ahash/convert.rs salt/src/state/hasher.rs salt/src/types.rs" | ||
| ;; | ||
| *) | ||
| echo "" | ||
| ;; | ||
| esac | ||
| } | ||
|
|
||
| if ! command -v mutate >/dev/null 2>&1; then | ||
| echo "universalmutator is required: pip install universalmutator" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| packs="${*:-trie canon endian}" | ||
|
|
||
| rm -rf "$OUT_DIR" | ||
| mkdir -p "$OUT_DIR/mutants" | ||
|
|
||
| # manifest lines: <pack>\t<source-file>\t<mutant-file> | ||
| manifest="$OUT_DIR/manifest.txt" | ||
| : > "$manifest" | ||
|
|
||
| for pack in $packs; do | ||
| targets="$(targets_for "$pack")" | ||
| if [ -z "$targets" ]; then | ||
| echo "unknown pack: $pack (expected: trie, canon, endian)" >&2 | ||
| exit 2 | ||
| fi | ||
| rules="$ROOT_DIR/mutants/operators/$pack.rules" | ||
|
|
||
| for src in $targets; do | ||
| # Restrict mutation to production lines: everything before the first | ||
| # top-level #[cfg(test)]. Mutants inside test code are pure noise. | ||
| # grep exits nonzero when a file has no test module at all. | ||
| cutoff="$(grep -n '^#\[cfg(test)\]' "$src" | head -1 | cut -d: -f1 || true)" | ||
| if [ -z "$cutoff" ]; then | ||
| cutoff=$(( $(wc -l < "$src") + 1 )) | ||
| fi | ||
| lines_file="$OUT_DIR/prod-lines.txt" | ||
| seq 1 $((cutoff - 1)) > "$lines_file" | ||
|
|
||
| mdir="$OUT_DIR/mutants/$pack/$(basename "$src" .rs)" | ||
| mkdir -p "$mdir" | ||
| # mutate exits nonzero when a file yields no mutants; that is a normal | ||
| # outcome for scoped packs (e.g. no production match in this file). | ||
| mutate "$src" --only "$rules" --noCheck --lines "$lines_file" \ | ||
| --mutantDir "$mdir" > /dev/null || true | ||
|
vincent-k2026 marked this conversation as resolved.
Outdated
|
||
|
|
||
| for m in "$mdir"/*.rs; do | ||
| [ -e "$m" ] || continue | ||
| printf '%s\t%s\t%s\n' "$pack" "$src" "$m" >> "$manifest" | ||
|
vincent-k2026 marked this conversation as resolved.
Outdated
|
||
| done | ||
| done | ||
| done | ||
|
|
||
| total="$(wc -l < "$manifest")" | ||
| if [ "$total" -eq 0 ]; then | ||
| echo "no mutants generated at all - check the rules files and targets" >&2 | ||
| exit 2 | ||
| fi | ||
| echo "generated $total mutants (packs: $packs)" | ||
|
|
||
| if [ "$GEN_ONLY" = "1" ]; then | ||
| echo "GEN_ONLY=1: skipping analysis; mutants are in $OUT_DIR/mutants/" | ||
| exit 0 | ||
| fi | ||
|
|
||
| # Restore the working tree if the analysis loop is interrupted mid-mutant. | ||
| cur_src="" | ||
| restore() { | ||
| if [ -n "$cur_src" ] && [ -f "$OUT_DIR/.orig.rs" ]; then | ||
| cp "$OUT_DIR/.orig.rs" "$cur_src" | ||
| fi | ||
| } | ||
| trap restore EXIT | ||
|
|
||
| invalid=0 | ||
| killed=0 | ||
| survivors_file="$OUT_DIR/survivors.txt" | ||
| : > "$survivors_file" | ||
|
|
||
| i=0 | ||
| while IFS="$(printf '\t')" read -r pack src mutant; do | ||
| i=$((i + 1)) | ||
| cur_src="$src" | ||
| cp "$src" "$OUT_DIR/.orig.rs" | ||
| cp "$mutant" "$src" | ||
| label="[$i/$total] $pack/$(basename "$mutant")" | ||
|
|
||
| if ! cargo check $CARGO_ARGS > /dev/null 2>&1; then | ||
| invalid=$((invalid + 1)) | ||
| echo "$label: invalid (does not compile)" | ||
| elif ! cargo nextest run $CARGO_ARGS > /dev/null 2>&1; then | ||
|
vincent-k2026 marked this conversation as resolved.
Outdated
|
||
| killed=$((killed + 1)) | ||
| echo "$label: killed" | ||
| else | ||
| echo "$label: SURVIVED" | ||
| { | ||
| echo "== $pack $src ($(basename "$mutant"))" | ||
| diff "$OUT_DIR/.orig.rs" "$src" || true | ||
| echo | ||
| } >> "$survivors_file" | ||
| fi | ||
|
|
||
| cp "$OUT_DIR/.orig.rs" "$src" | ||
| cur_src="" | ||
| done < "$manifest" | ||
|
|
||
| survived=$((total - invalid - killed)) | ||
| status="PASS" | ||
| if [ "$survived" -gt 0 ]; then | ||
| status="$survived SURVIVORS" | ||
| fi | ||
|
|
||
| report="$OUT_DIR/report.md" | ||
| { | ||
| echo "## Spec mutation - $status" | ||
| echo | ||
| echo "Packs: \`$packs\`" | ||
| echo | ||
| echo "- generated: $total" | ||
| echo "- killed: $killed" | ||
| echo "- invalid (did not compile): $invalid" | ||
| echo "- survived: $survived" | ||
| if [ -s "$survivors_file" ]; then | ||
| echo | ||
| echo "### Survivors" | ||
| echo | ||
| echo "Each survivor is a semantic fault no test noticed: add a killing" | ||
| echo "test, or document why it is equivalent." | ||
| echo | ||
| echo '```diff' | ||
| cat "$survivors_file" | ||
| echo '```' | ||
| fi | ||
| } > "$report" | ||
|
|
||
| echo | ||
| cat "$report" | ||
|
|
||
| if [ "$survived" -gt 0 ]; then | ||
| exit 1 | ||
| fi | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.