Skip to content

Commit bf76bc7

Browse files
authored
Merge pull request #6 from daedalus/copilot/subtree-population-crossover
Add subtree-population crossover to grammar TreeMutator
2 parents 36a7885 + 758aa65 commit bf76bc7

5 files changed

Lines changed: 309 additions & 14 deletions

File tree

‎docs/DEEP_DIVE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ For production and sensitive binaries using AFL family fuzzers is the best cours
4343
- `popcount_lock` (← `diehard_count_1s_byte`/`_stream`): bytes pinned to a single Hamming weight, collapsing the five-letter popcount alphabet to one — bit-packed formats, UTF-8/Base64 validity classes, ECC and constant-weight codes, SIMD popcount scalar tails.
4444
Note on provenance: dieharder is GPL-2 and this tool is MIT; the module contains none of its code. The constructions derive from the public test descriptions (Marsaglia's `tests.txt`, the dieharder manual) and the underlying combinatorics.
4545
- **Tree mutator** (`lightweight_tree_mutate`): Radamsa-style delimiter-based tree mutations (delete, duplicate, swap, stutter) with correct round-trip invariant — unmatched delimiters are preserved, never healed
46+
- **Grammar tree mutator subtree-population crossover** (`core/grammar.py::SubtreePopulation`, `TreeMutator._tree_splice`): port of GRIIN (ASE '23) / Grammarinator×AFL++ (2026) — a bounded, reservoir-sampled per-rule pool of interior nodes harvested from every parsed corpus entry, so `grammar_tree_mutate` can splice in a same-rule subtree donated by a *different* corpus entry instead of only regenerating from the grammar or cloning within the same tree; falls back to subtree swap when no donor of a matching rule exists yet. Population is built incrementally in `services/operators.py::_op_grammar_tree_mutate`, tracking the last-harvested corpus index so growth is O(new seeds) per call, not a full corpus rescan
4647
- **FrameShift**: automatic length-field tracking — discovers and adjusts length/count fields during insertions/deletions, applied as universal post-processing after every mutation
4748
- **Dictionary support**: inject protocol tokens from dictionary files
4849
- **Markov chain**: learn byte-level transition probabilities from corpus, generate statistically similar inputs, persist across runs

‎docs/web_research_port_candidates_2026-08.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ it is now wired into `services/report.py::_crash_signatures`. #4–7 remain: #4/
2424
require `afl_shim.c` changes (excluded from this pass); #6/#7 are `L` effort
2525
needing new feedback plumbing, not yet started.
2626

27+
**2026-08-24 (later)**: #8 (Subtree-population crossover) landed. `TreeMutator`
28+
already documented a "subtree splice" op that was never implemented; added
29+
`SubtreePopulation` (bounded reservoir per rule name) and `_tree_splice`,
30+
wired into `mutate_tree` and incrementally populated from the corpus in
31+
`services/operators.py::_op_grammar_tree_mutate`.
32+
2733
### Tier 1 — quick wins
2834

2935
| # | Candidate | Source | Mechanism | Lands in | Effort |
@@ -40,7 +46,7 @@ needing new feedback plumbing, not yet started.
4046

4147
| # | Candidate | Source | Mechanism | Lands in | Effort |
4248
|---|---|---|---|---|---|
43-
| 8 | Subtree-population crossover | GRIIN (ASE '23); Grammarinator×AFL++ (2026) | Global subtree population for grammar-aware tree crossover — measured as the biggest single win of that integration | upgrade of `tree_mutator.py` | L–M |
49+
| 8 | Subtree-population crossover (✅ landed 2026-08-24) | GRIIN (ASE '23); Grammarinator×AFL++ (2026) | Global subtree population for grammar-aware tree crossover — measured as the biggest single win of that integration | `core/grammar.py::SubtreePopulation`/`TreeMutator._tree_splice`, wired in `services/operators.py::_op_grammar_tree_mutate` | L–M |
4450
| 9 | FormatFuzzer decision seeds | FormatFuzzer (USENIX Sec '21, `uds-se/FormatFuzzer`) | Compiles community 010 Editor binary templates (170+ formats incl. MP4/PNG/AVI/ZIP) into parser+generator pairs; byte fuzzer mutates choice bits while output stays valid | template-driven generator family generalizing the hand-written mutators | M |
4551
| 10 | Grammar-aware reduction | Perses (ICSE '18); ProbDD/WDD (ICSE '25) | Reduce along grammar/token trees (~2% of ddmin output size); ddmin-to-fixpoint alone shrinks ~68% further | `tmin.py` | L–M |
4652
| 11 | Reaching-probability directed mode | SelectFuzz (IEEE S&P '23) | Block fitness = averaged successor reaching probability instead of graph distance; instrument only target-relevant blocks (<2% of reachable BBs) so irrelevant coverage never pollutes feedback | `distance.py` math + one LLVM pass | L–M |

‎src/fuzzer_tool/core/grammar.py‎

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,53 @@ def __repr__(self):
529529
return f"Node({self.rule!r}, children={len(self.children)})"
530530

531531

532+
class SubtreePopulation:
533+
"""Global pool of subtrees harvested across many corpus entries.
534+
535+
Port of the "subtree-population crossover" idea (GRIIN, ASE '23;
536+
Grammarinator x AFL++, 2026): grammar-aware crossover is far more
537+
productive when the replacement subtree can come from *any* corpus
538+
entry that shares the target's rule, not only a freshly generated
539+
subtree or a clone from within the same tree. This class keeps a
540+
bounded, per-rule reservoir of interior nodes so ``TreeMutator``
541+
can splice in subtrees seen elsewhere in the corpus.
542+
543+
Reservoir sampling (Algorithm R) bounds memory to ``max_per_rule``
544+
nodes per rule regardless of corpus size, while still giving every
545+
harvested node an equal chance of ending up in the pool.
546+
"""
547+
548+
def __init__(self, max_per_rule: int = 64):
549+
self.max_per_rule = max_per_rule
550+
self._pools: dict[str, list[TreeNode]] = {}
551+
self._seen: dict[str, int] = {}
552+
553+
def add(self, tree: TreeNode, rng=None) -> None:
554+
"""Harvest every interior node of *tree* into the population."""
555+
rand = rng or random
556+
for node in tree.collect_interior():
557+
pool = self._pools.setdefault(node.rule, [])
558+
seen = self._seen.get(node.rule, 0)
559+
self._seen[node.rule] = seen + 1
560+
if len(pool) < self.max_per_rule:
561+
pool.append(node)
562+
continue
563+
j = rand.randint(0, seen)
564+
if j < self.max_per_rule:
565+
pool[j] = node
566+
567+
def sample(self, rule: str, rng=None) -> "TreeNode | None":
568+
"""Return a random subtree previously harvested for *rule*, or None."""
569+
pool = self._pools.get(rule)
570+
if not pool:
571+
return None
572+
rand = rng or random
573+
return pool[rand.randint(0, len(pool) - 1)]
574+
575+
def __len__(self) -> int:
576+
return sum(len(pool) for pool in self._pools.values())
577+
578+
532579
class TreeMutator:
533580
"""Parse inputs against a grammar into trees and mutate at the tree level.
534581
@@ -679,8 +726,13 @@ def _infer_chunk_size(self) -> int:
679726
# Tree-level mutations
680727
# ------------------------------------------------------------------
681728

682-
def mutate_tree(self, tree: TreeNode, max_len: int = 4096, rng=None) -> bytes:
683-
self._rng = rng or random
729+
def mutate_tree(
730+
self,
731+
tree: TreeNode,
732+
max_len: int = 4096,
733+
rng=None,
734+
population: SubtreePopulation | None = None,
735+
) -> bytes:
684736
"""Apply a random tree-level mutation and serialize back to bytes.
685737
686738
Operations:
@@ -689,21 +741,31 @@ def mutate_tree(self, tree: TreeNode, max_len: int = 4096, rng=None) -> bytes:
689741
2. Subtree delete: remove a node (replace with empty)
690742
3. Subtree duplicate: clone a node and insert the copy nearby
691743
4. Subtree splice: replace a node with a subtree from another
692-
corpus entry's tree
744+
corpus entry's tree (see ``SubtreePopulation``); falls back to
745+
subtree swap when no *population* is supplied or no donor of a
746+
matching rule has been harvested yet
693747
5. Rule substitution: replace a node with a different alternative
694748
from the same grammar rule
749+
750+
Args:
751+
population: Optional cross-corpus subtree pool for op 4.
752+
Callers should keep one long-lived ``SubtreePopulation``
753+
per fuzzer run and feed it every parsed corpus tree.
695754
"""
755+
self._rng = rng or random
696756
if tree.is_leaf:
697757
return self._mutate_leaf(tree, max_len)
698758

699-
op = (self._rng or random).randint(0, 4)
759+
op = (self._rng or random).randint(0, 5)
700760
if op == 0:
701761
return self._tree_swap(tree, max_len)
702762
elif op == 1:
703763
return self._tree_delete(tree, max_len)
704764
elif op == 2:
705765
return self._tree_duplicate(tree, max_len)
706766
elif op == 3:
767+
return self._tree_splice(tree, max_len, population)
768+
elif op == 4:
707769
return self._tree_rule_sub(tree, max_len)
708770
else:
709771
return self._mutate_leaf(tree, max_len)
@@ -747,6 +809,40 @@ def _tree_duplicate(self, tree: TreeNode, max_len: int) -> bytes:
747809
parent.children.insert(idx + 1, clone)
748810
return tree.serialize()[:max_len]
749811

812+
def _tree_splice(
813+
self, tree: TreeNode, max_len: int, population: SubtreePopulation | None
814+
) -> bytes:
815+
"""Replace a random interior node with a same-rule subtree donated
816+
by a different corpus entry (subtree-population crossover).
817+
818+
Falls back to ``_tree_swap`` (freshly-generated subtree) when no
819+
population was supplied or it hasn't harvested a matching rule yet
820+
— that keeps this op always productive instead of a silent no-op.
821+
"""
822+
if population is None or not len(population):
823+
return self._tree_swap(tree, max_len)
824+
targets = tree.collect_interior()
825+
if not targets:
826+
return tree.serialize()[:max_len]
827+
rng = self._rng or random
828+
# Try a bounded number of random targets rather than shuffling the
829+
# whole list — RandPool doesn't implement shuffle, and one match is
830+
# all a single mutation needs.
831+
tries = min(len(targets), 8)
832+
for _ in range(tries):
833+
target = targets[rng.randint(0, len(targets) - 1)]
834+
donor = population.sample(target.rule, rng=rng)
835+
if donor is None or donor is target:
836+
continue
837+
if target is tree:
838+
# Root itself is the splice point: it has no parent to
839+
# rewrite in place, so the donor subtree simply becomes the
840+
# whole output.
841+
return self._clone_tree(donor).serialize()[:max_len]
842+
self._replace_in_tree(tree, target, self._clone_tree(donor))
843+
return tree.serialize()[:max_len]
844+
return self._tree_swap(tree, max_len)
845+
750846
def _tree_rule_sub(self, tree: TreeNode, max_len: int) -> bytes:
751847
"""Replace a node with a different alternative from the same rule."""
752848
targets = tree.collect_interior()

‎src/fuzzer_tool/services/operators.py‎

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1900,17 +1900,35 @@ def _op_grammar_mutate(self, buf, _byte_idx, _data):
19001900

19011901
def _op_grammar_tree_mutate(self, buf, _byte_idx, data):
19021902
if self.f.grammar:
1903-
from fuzzer_tool.core.grammar import TreeMutator
1904-
1905-
if not hasattr(self.f, "_tree_mutator"):
1906-
self.f._tree_mutator = TreeMutator(self.f.grammar)
1907-
parent_meta = self.f.seed_meta.get(data)
1903+
from fuzzer_tool.core.grammar import SubtreePopulation, TreeMutator
1904+
1905+
f = self.f
1906+
rng = f._rand_pool
1907+
if not hasattr(f, "_tree_mutator"):
1908+
f._tree_mutator = TreeMutator(f.grammar)
1909+
f._subtree_population = SubtreePopulation()
1910+
f._subtree_pop_next_idx = 0
1911+
parent_meta = f.seed_meta.get(data)
19081912
stride = parent_meta.get("record_stride") if parent_meta else None
1909-
tree = self.f._tree_mutator.parse(bytes(buf), chunk_size=stride)
1913+
tree = f._tree_mutator.parse(bytes(buf), chunk_size=stride)
1914+
1915+
# Incrementally harvest newly-added corpus entries into the
1916+
# shared subtree population (subtree-population crossover, see
1917+
# docs/web_research_port_candidates_2026-08.md #8) instead of
1918+
# reparsing the whole corpus on every call.
1919+
corpus = getattr(f, "corpus", None) or []
1920+
next_idx = f._subtree_pop_next_idx
1921+
if next_idx > len(corpus):
1922+
next_idx = 0 # corpus was replaced/shrunk — restart harvesting
1923+
for seed in corpus[next_idx:]:
1924+
donor_tree = f._tree_mutator.parse(bytes(seed))
1925+
f._subtree_population.add(donor_tree, rng=rng)
1926+
f._subtree_pop_next_idx = len(corpus)
1927+
19101928
return bytearray(
1911-
self.f._tree_mutator.mutate_tree(
1912-
tree, max_len=self.f.max_len, rng=self.f._rand_pool
1913-
)[: self.f.max_len]
1929+
f._tree_mutator.mutate_tree(
1930+
tree, max_len=f.max_len, rng=rng, population=f._subtree_population
1931+
)[: f.max_len]
19141932
)
19151933

19161934
def _op_versifier_generate(self, buf, _byte_idx, _data):
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Tests for subtree-population crossover (docs/web_research_port_candidates_2026-08.md #8).
2+
3+
Port of GRIIN (ASE '23) / Grammarinator x AFL++ (2026): grammar-aware tree
4+
crossover should be able to splice in a subtree harvested from a *different*
5+
corpus entry, not just regenerate one from scratch or clone within the same
6+
tree. ``TreeMutator``'s docstring already promised this as op 4
7+
("Subtree splice") but the implementation never existed until now.
8+
"""
9+
10+
import random
11+
12+
from fuzzer_tool.core.grammar import Grammar, SubtreePopulation, TreeMutator
13+
from fuzzer_tool.services.operators import OperatorEngine
14+
15+
from .support.operator_env import make_minimal_fuzzer
16+
17+
18+
def _json_grammar() -> Grammar:
19+
g = Grammar()
20+
g.parse('root = {"key":"value"}')
21+
return g
22+
23+
24+
class TestSubtreePopulation:
25+
def test_sample_unseen_rule_returns_none(self):
26+
pop = SubtreePopulation()
27+
assert pop.sample("value") is None
28+
29+
def test_add_harvests_interior_nodes(self):
30+
tm = TreeMutator(_json_grammar())
31+
tree = tm.parse(b'{"key":"value"}')
32+
pop = SubtreePopulation()
33+
pop.add(tree, rng=random.Random(1))
34+
assert len(pop) > 0
35+
assert pop.sample("root") is not None
36+
37+
@staticmethod
38+
def _interior_node(rule: str, marker: bytes):
39+
"""A one-node-deep interior node: ``collect_interior`` only reports
40+
non-leaf nodes, so a bare ``TreeNode(rule=..., data=...)`` (a leaf)
41+
would never be harvested."""
42+
from fuzzer_tool.core.grammar import TreeNode
43+
44+
return TreeNode(rule=rule, children=[TreeNode(rule="leaf", data=marker)])
45+
46+
def test_reservoir_bounded_by_max_per_rule(self):
47+
"""Falsification: harvesting more nodes than the cap must never
48+
grow the pool past ``max_per_rule`` for a single rule."""
49+
pop = SubtreePopulation(max_per_rule=4)
50+
rng = random.Random(7)
51+
for i in range(200):
52+
pop.add(self._interior_node("value", str(i).encode()), rng=rng)
53+
assert len(pop._pools["value"]) == 4
54+
55+
def test_reservoir_sampling_reaches_late_items(self):
56+
"""Adversarial: an item harvested long after the pool filled up
57+
must still have a nonzero chance of surviving eviction — a buggy
58+
reservoir (e.g. only replacing index 0) would never let it in."""
59+
seen_late_item = False
60+
for trial in range(200):
61+
pop = SubtreePopulation(max_per_rule=4)
62+
rng = random.Random(trial)
63+
for i in range(50):
64+
pop.add(self._interior_node("value", str(i).encode()), rng=rng)
65+
if any(n.children[0].data == b"49" for n in pop._pools["value"]):
66+
seen_late_item = True
67+
break
68+
assert seen_late_item, "item #49 never survived reservoir sampling across 200 trials"
69+
70+
71+
class TestTreeSplice:
72+
def test_splice_falls_back_without_population(self):
73+
"""No population supplied -> behaves like a plain subtree swap,
74+
never raises and always returns bytes that respect max_len."""
75+
tm = TreeMutator(_json_grammar())
76+
tm._rng = random.Random(0)
77+
tree = tm.parse(b'{"key":"value"}')
78+
result = tm._tree_splice(tree, max_len=64, population=None)
79+
assert isinstance(result, bytes)
80+
assert len(result) <= 64
81+
82+
def test_splice_falls_back_on_empty_population(self):
83+
tm = TreeMutator(_json_grammar())
84+
tm._rng = random.Random(0)
85+
tree = tm.parse(b'{"key":"value"}')
86+
result = tm._tree_splice(tree, max_len=64, population=SubtreePopulation())
87+
assert isinstance(result, bytes)
88+
89+
def test_splice_grafts_donor_subtree(self):
90+
"""Splicing from a population seeded with a donor tree must be able
91+
to pull in bytes that never appeared in the original tree."""
92+
grammar = _json_grammar()
93+
tm = TreeMutator(grammar)
94+
target_tree = tm.parse(b'{"key":"value"}')
95+
donor_tree = tm.parse(b'{"other":"DONOR_MARKER_XYZ"}')
96+
97+
pop = SubtreePopulation()
98+
pop.add(donor_tree, rng=random.Random(3))
99+
100+
found_donor_bytes = False
101+
for seed in range(64):
102+
tm._rng = random.Random(seed)
103+
clone = tm._clone_tree(target_tree)
104+
out = tm._tree_splice(clone, max_len=4096, population=pop)
105+
if b"DONOR_MARKER_XYZ" in out or b"other" in out:
106+
found_donor_bytes = True
107+
break
108+
assert found_donor_bytes, "splice never grafted in bytes from the donor tree"
109+
110+
def test_mutate_tree_op3_is_splice(self):
111+
"""The op-3 branch in mutate_tree must route to _tree_splice, not
112+
silently stay a no-op (regression for the missing implementation)."""
113+
grammar = _json_grammar()
114+
tm = TreeMutator(grammar)
115+
tree = tm.parse(b'{"key":"value"}')
116+
donor_tree = tm.parse(b'{"other":"DONOR_MARKER_XYZ"}')
117+
pop = SubtreePopulation()
118+
pop.add(donor_tree, rng=random.Random(3))
119+
120+
class _FixedOpRng:
121+
"""Forces mutate_tree's op selection to pick splice (op index 3)."""
122+
123+
def randint(self, a, b):
124+
if b == 5: # the op-selection roll in mutate_tree
125+
return 3
126+
return random.Random(0).randint(a, b)
127+
128+
found_donor_bytes = False
129+
for _ in range(32):
130+
clone = tm._clone_tree(tree)
131+
out = tm.mutate_tree(clone, max_len=4096, rng=_FixedOpRng(), population=pop)
132+
if b"DONOR_MARKER_XYZ" in out or b"other" in out:
133+
found_donor_bytes = True
134+
break
135+
assert found_donor_bytes
136+
137+
138+
class TestGrammarTreeMutateOperatorWiring:
139+
def _engine_with_grammar(self, corpus):
140+
f = make_minimal_fuzzer(seed=0x5EED)
141+
f.grammar = _json_grammar()
142+
f.corpus = corpus
143+
return OperatorEngine(f)
144+
145+
def test_builds_and_reuses_population_across_calls(self):
146+
corpus = [b'{"a":"seed_one"}', b'{"b":"seed_two_marker"}']
147+
engine = self._engine_with_grammar(corpus)
148+
buf = bytearray(b'{"key":"value"}')
149+
engine._op_grammar_tree_mutate(buf, 0, bytes(buf))
150+
f = engine.f
151+
assert hasattr(f, "_subtree_population")
152+
assert len(f._subtree_population) > 0
153+
assert f._subtree_pop_next_idx == len(corpus)
154+
155+
# Corpus growth is picked up incrementally on the next call.
156+
corpus.append(b'{"c":"seed_three"}')
157+
engine._op_grammar_tree_mutate(buf, 0, bytes(buf))
158+
assert f._subtree_pop_next_idx == len(corpus)
159+
160+
def test_grammar_tree_mutate_returns_bytes(self):
161+
corpus = [b'{"a":"seed_one"}']
162+
engine = self._engine_with_grammar(corpus)
163+
buf = bytearray(b'{"key":"value"}')
164+
result = engine._op_grammar_tree_mutate(buf, 0, bytes(buf))
165+
assert isinstance(result, bytearray)
166+
167+
def test_no_grammar_returns_none(self):
168+
"""Adversarial: without a grammar the op must be a clean no-op,
169+
never touching the (nonexistent) population machinery."""
170+
f = make_minimal_fuzzer(seed=1)
171+
f.grammar = None
172+
engine = OperatorEngine(f)
173+
buf = bytearray(b"whatever")
174+
assert engine._op_grammar_tree_mutate(buf, 0, bytes(buf)) is None

0 commit comments

Comments
 (0)