Skip to content

Improve the Character default mutator: mutate only reaches 8 fixed seeds #48

Description

@twof

Summary

Character.defaultMutator's mutate is far weaker than its generate. Mutation can only ever return one of 8 hard-coded seeds, ignoring the input character entirely — so it does no neighborhood or structural exploration and cannot reach most of the character space.

Where

Sources/PropertyTestingKit/Fuzzing/Mutators/MutatorProviding/Character+MutatorProviding.swift

private let _characterSeeds: [Character] = ["a", "Z", "0", " ", "\n", "\t", "😄", "\0"]

private func _characterMutate(_ value: Character, _ rng: inout FastRNG) -> Character {
    // Pick ONE applicable seed (any seed other than the current value).
    let candidates = _characterSeeds.filter { $0 != value }
    guard let mutant = candidates.randomElement(using: &rng) else { return value }
    return mutant
}

Why this is weak

  • Tiny reachable set. A mutant is always one of the 8 seeds. From any input, mutation is effectively a 7-way categorical jump; the overwhelming majority of Character values are unreachable as mutants.
  • No locality. It never explores near the input — e.g. a → b, case flip a → A, digit 5 → 6, adjacent Unicode scalar. Coverage-guided search benefits from small steps that flip one branch at a time; a categorical jump to a fixed seed can't do that.
  • generate already knows more than mutate. _characterGenerate draws from ASCII-printable, lowercase/uppercase letters, digits, whitespace, and an emoji set — a much richer space than the 8 mutation seeds. That structured knowledge isn't used on the mutation path.

Suggested directions

Make mutate pick uniformly among a set of applicable, non-identity strategies (same pattern recently applied to the port/HTTP/percentage/empty-string mutators), e.g.:

  • Scalar neighborhood: increment/decrement the input's Unicode scalar by a small delta.
  • Case flip: a ↔ A when the input is cased.
  • Class-local step: within letters/digits, move to an adjacent member (5 → 4/6).
  • Class jump: swap to a random member of a different class (letter ↔ digit ↔ whitespace ↔ symbol ↔ emoji) — reusing the richer pools generate already defines instead of the 8-seed list.
  • Keep the existing identity filter so a mutant is never equal to the input.

Acceptance

  • mutate reaches a substantially larger and more structured set than the current 8 seeds.
  • Mutation includes at least one locality-preserving step (scalar/case/class-local).
  • No identity mutants (already covered by IdentityMutantRateTests; add Character-specific coverage asserting reachability of the new strategies).

Context: surfaced while removing identity mutants from the single-value mutators (PR #42).

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