Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/spec-mutation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Spec Mutation
Comment thread
vincent-k2026 marked this conversation as resolved.
Outdated

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
Comment thread
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
Comment thread
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
60 changes: 60 additions & 0 deletions mutants/operators/README.md
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.
23 changes: 23 additions & 0 deletions mutants/operators/canon.rules
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)
Comment thread
vincent-k2026 marked this conversation as resolved.
Outdated
10 changes: 10 additions & 0 deletions mutants/operators/endian.rules
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
Comment thread
vincent-k2026 marked this conversation as resolved.
Outdated
32 changes: 32 additions & 0 deletions mutants/operators/trie.rules
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
Comment thread
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).
Comment thread
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
176 changes: 176 additions & 0 deletions scripts/spec_mutation.sh
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
Comment thread
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"
Comment thread
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
Comment thread
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
Loading