Skip to content

Add subtree-population crossover to grammar TreeMutator - #6

Merged
daedalus merged 2 commits into
masterfrom
copilot/subtree-population-crossover
Aug 24, 2026
Merged

Add subtree-population crossover to grammar TreeMutator#6
daedalus merged 2 commits into
masterfrom
copilot/subtree-population-crossover

Conversation

Copilot AI commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds subtree-population crossover to the grammar TreeMutator.

Changes

  • Add subtree-population crossover to grammar TreeMutator
  • Fix docstring placement and test comment per code review

Summary by Sourcery

Enable grammar tree mutation to exchange compatible subtrees across corpus entries.

New Features:

  • Add grammar-aware subtree-population crossover that reuses same-rule subtrees harvested from across corpus entries during tree mutation.

Bug Fixes:

  • Implement the previously documented subtree-splice mutation and provide productive fallback behavior when no matching donor is available.

Enhancements:

  • Maintain bounded per-rule subtree reservoirs with incremental corpus harvesting to support efficient cross-seed mutation.

Documentation:

  • Document subtree-population crossover and mark the corresponding research candidate as landed.

Tests:

  • Add coverage for reservoir sampling, subtree splicing, mutation routing, operator integration, and no-grammar behavior.

Copilot AI and others added 2 commits August 24, 2026 20:56
Co-authored-by: daedalus <115175+daedalus@users.noreply.github.com>
Co-authored-by: daedalus <115175+daedalus@users.noreply.github.com>
@sourcery-ai

sourcery-ai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduces grammar-aware subtree-population crossover by maintaining bounded, reservoir-sampled per-rule donor pools harvested incrementally from the corpus, then using compatible cloned subtrees during tree mutation with productive fallback behavior. Documentation and regression tests cover reservoir sampling, splice dispatch, operator lifecycle, and edge cases.

Sequence diagram for incremental subtree-population crossover

sequenceDiagram
    participant Operator as GrammarTreeOperator
    participant Mutator as TreeMutator
    participant Population as SubtreePopulation
    participant Corpus as Corpus

    GrammarTreeOperator->>TreeMutator: parse(buf, chunk_size)
    GrammarTreeOperator->>Corpus: read corpus[next_idx:]
    loop newly added corpus entries
        GrammarTreeOperator->>TreeMutator: parse(seed)
        TreeMutator-->>GrammarTreeOperator: donor_tree
        GrammarTreeOperator->>SubtreePopulation: add(donor_tree, rng)
    end
    GrammarTreeOperator->>TreeMutator: mutate_tree(tree, max_len, rng, population)
    TreeMutator->>SubtreePopulation: sample(target.rule, rng)
    SubtreePopulation-->>TreeMutator: donor subtree
    TreeMutator->>TreeMutator: _clone_tree(donor)
    TreeMutator-->>GrammarTreeOperator: serialized mutated tree
Loading

Flow diagram for grammar tree mutation with donor fallback

flowchart TD
    A[Parse input tree] --> B[Harvest new corpus trees]
    B --> C[mutate_tree]
    C --> D{Subtree splice selected?}
    D -- No --> E[Run selected mutation]
    D -- Yes --> F{Matching donor available?}
    F -- Yes --> G[Clone and replace same-rule subtree]
    F -- No --> H[_tree_swap]
    G --> I[Serialize and enforce max_len]
    H --> I
    E --> I
Loading

File-Level Changes

Change Details Files
Adds a bounded, per-grammar-rule subtree reservoir for cross-corpus donor selection.
  • Harvests interior nodes with Algorithm R reservoir sampling.
  • Supports random same-rule donor sampling with configurable per-rule capacity.
src/fuzzer_tool/core/grammar.py
Implements subtree-splice mutation and integrates it into tree mutation dispatch.
  • Adds donor subtree replacement with cloning and root handling.
  • Falls back to subtree swap when no compatible donor is available.
  • Expands mutation selection to include the splice operation.
src/fuzzer_tool/core/grammar.py
Wires incremental corpus harvesting into the grammar tree mutation operator.
  • Maintains a long-lived population and next-corpus-index state per fuzzer.
  • Parses only newly added corpus entries and resets harvesting when the corpus shrinks or is replaced.
  • Passes the shared population and fuzzer RNG into tree mutation.
src/fuzzer_tool/services/operators.py
Documents the landed crossover feature and adds focused regression coverage.
  • Documents the reservoir, incremental harvesting, and fallback behavior.
  • Tests reservoir bounds and late-item sampling, splice behavior, dispatch, operator reuse, corpus growth, and no-grammar behavior.
  • Corrects the operation documentation and related research-candidate status.
docs/DEEP_DIVE.md
docs/web_research_port_candidates_2026-08.md
tests/test_subtree_population_crossover.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@daedalus
daedalus marked this pull request as ready for review August 24, 2026 21:12
Copilot AI lite review requested due to automatic review settings August 24, 2026 21:12
@daedalus
daedalus merged commit bf76bc7 into master Aug 24, 2026
1 check passed
@daedalus
daedalus deleted the copilot/subtree-population-crossover branch August 24, 2026 21:12

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="src/fuzzer_tool/services/operators.py" line_range="1920-1925" />
<code_context>
+            # docs/web_research_port_candidates_2026-08.md #8) instead of
+            # reparsing the whole corpus on every call.
+            corpus = getattr(f, "corpus", None) or []
+            next_idx = f._subtree_pop_next_idx
+            if next_idx > len(corpus):
+                next_idx = 0  # corpus was replaced/shrunk — restart harvesting
+            for seed in corpus[next_idx:]:
+                donor_tree = f._tree_mutator.parse(bytes(seed))
+                f._subtree_population.add(donor_tree, rng=rng)
+            f._subtree_pop_next_idx = len(corpus)
+
</code_context>
<issue_to_address>
**issue (broader_impact):** When the corpus is replaced with a different corpus of the same length, `_subtree_pop_next_idx` remains equal to that length, so no new entries are harvested and the population continues supplying subtrees from the old corpus. When the corpus shrinks, resetting the index still leaves the old pools intact, so stale donors from removed seeds remain eligible indefinitely.

**Triggers:** When corpus minimization, seed transformation, or corpus synchronization replaces or removes entries without increasing the list length.

**Suggested fix:** Track the corpus identity/content generation and clear or rebuild the population whenever entries are replaced or removed, rather than using only the list length as the change detector.
</issue_to_address>

### Comment 2
<location path="src/fuzzer_tool/core/grammar.py" line_range="540-565" />
<code_context>
+    bounded, per-rule reservoir of interior nodes so ``TreeMutator``
</code_context>
<issue_to_address>
**issue (bug_risk):** The population stores no donor provenance, so `_tree_splice` can select a subtree harvested from the same corpus entry as the target. With a one-entry corpus whose input is being mutated, the matching root donor is the same serialized tree and operation 3 returns the original bytes instead of performing a crossover or using the productive fallback.

**Triggers:** When the corpus contains only one entry, or when the current input is also present in the corpus and the reservoir selects its subtrees.

**Suggested fix:** Store the source corpus entry with each donor and exclude donors from the current entry; fall back to `_tree_swap` when no donor from a different entry is available.
</issue_to_address>

Sourcery assessment

Approval pending. 2 findings to address first.

Blocking findings: src/fuzzer_tool/services/operators.py:1925, src/fuzzer_tool/core/grammar.py:565


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +1920 to +1925
next_idx = f._subtree_pop_next_idx
if next_idx > len(corpus):
next_idx = 0 # corpus was replaced/shrunk — restart harvesting
for seed in corpus[next_idx:]:
donor_tree = f._tree_mutator.parse(bytes(seed))
f._subtree_population.add(donor_tree, rng=rng)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (broader_impact): When the corpus is replaced with a different corpus of the same length, _subtree_pop_next_idx remains equal to that length, so no new entries are harvested and the population continues supplying subtrees from the old corpus. When the corpus shrinks, resetting the index still leaves the old pools intact, so stale donors from removed seeds remain eligible indefinitely.

Triggers: When corpus minimization, seed transformation, or corpus synchronization replaces or removes entries without increasing the list length.

Suggested fix: Track the corpus identity/content generation and clear or rebuild the population whenever entries are replaced or removed, rather than using only the list length as the change detector.

Comment on lines +540 to +565
bounded, per-rule reservoir of interior nodes so ``TreeMutator``
can splice in subtrees seen elsewhere in the corpus.

Reservoir sampling (Algorithm R) bounds memory to ``max_per_rule``
nodes per rule regardless of corpus size, while still giving every
harvested node an equal chance of ending up in the pool.
"""

def __init__(self, max_per_rule: int = 64):
self.max_per_rule = max_per_rule
self._pools: dict[str, list[TreeNode]] = {}
self._seen: dict[str, int] = {}

def add(self, tree: TreeNode, rng=None) -> None:
"""Harvest every interior node of *tree* into the population."""
rand = rng or random
for node in tree.collect_interior():
pool = self._pools.setdefault(node.rule, [])
seen = self._seen.get(node.rule, 0)
self._seen[node.rule] = seen + 1
if len(pool) < self.max_per_rule:
pool.append(node)
continue
j = rand.randint(0, seen)
if j < self.max_per_rule:
pool[j] = node

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The population stores no donor provenance, so _tree_splice can select a subtree harvested from the same corpus entry as the target. With a one-entry corpus whose input is being mutated, the matching root donor is the same serialized tree and operation 3 returns the original bytes instead of performing a crossover or using the productive fallback.

Triggers: When the corpus contains only one entry, or when the current input is also present in the corpus and the reservoir selects its subtrees.

Suggested fix: Store the source corpus entry with each donor and exclude donors from the current entry; fall back to _tree_swap when no donor from a different entry is available.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Fix root replacement fallback and donor-cache invalidation before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds grammar-aware subtree-population crossover using corpus-derived donor trees.

Changes:

  • Adds bounded subtree reservoirs and splice mutation.
  • Integrates incremental corpus harvesting.
  • Adds tests and documentation.
File summaries
File Summary
tests/test_subtree_population_crossover.py Adds crossover, reservoir, and integration tests.
src/fuzzer_tool/services/operators.py Integrates corpus harvesting; donor cache invalidation needs correction.
src/fuzzer_tool/core/grammar.py Implements reservoirs and splicing; root-only fallback can silently no-op.
docs/web_research_port_candidates_2026-08.md Updates research status.
docs/DEEP_DIVE.md Documents the feature.
Review details

Suppressed comments (3)

src/fuzzer_tool/core/grammar.py:835

  • donor is target cannot enforce the documented cross-entry splice: the target is parsed from the parent buffer, while donors are parsed separately from f.corpus, so even the current seed's matching node is a different object. When the parent is itself in the corpus, op 3 can therefore select its own subtree and return an unchanged clone instead of crossing over; retain donor-seed identity (or exclude the current seed) when sampling.
            donor = population.sample(target.rule, rng=rng)
            if donor is None or donor is target:

src/fuzzer_tool/services/operators.py:1925

  • This harvest includes the current parent seed, but the pool stores no donor provenance and _tree_splice can only compare object identity with the target. Because fuzz_one passes a corpus entry as data, op 3 can select that entry's reparsed subtree and return an unchanged mutation, contrary to the documented cross-entry crossover. Track the source and exclude the current parent when sampling, including entries already cached.
            for seed in corpus[next_idx:]:
                donor_tree = f._tree_mutator.parse(bytes(seed))
                f._subtree_population.add(donor_tree, rng=rng)

src/fuzzer_tool/services/operators.py:1925

  • This newly added corpus-wide parse has no failure boundary. TreeMutator.parse recursively descends nested braced input, so one sufficiently deep corpus seed can raise RecursionError while an unrelated parent is being mutated and abort the fuzzing iteration. Harvest donor trees with a bounded or exception-safe parse and skip an unparseable donor.
            for seed in corpus[next_idx:]:
                donor_tree = f._tree_mutator.parse(bytes(seed))
                f._subtree_population.add(donor_tree, rng=rng)
  • Files reviewed: 5/5 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +822 to +823
if population is None or not len(population):
return self._tree_swap(tree, max_len)
Comment on lines +1920 to +1922
next_idx = f._subtree_pop_next_idx
if next_idx > len(corpus):
next_idx = 0 # corpus was replaced/shrunk — restart harvesting
@daedalus

Copy link
Copy Markdown
Owner

@copilot Fix the code for all comments in this review thread.

When a review comment includes a suggested change, apply the suggestion exactly.

Do not make changes beyond what is described in the linked review thread.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants