From f8b765a9f89cf745e5a28bcf171e0b6dae68eb47 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 13 Mar 2026 05:54:48 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20feat:=20Push3=20evolution=20=E2=80=94=20?= =?UTF-8?q?crossover=20operator=20(#639)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- tools/push3-evolution/mutate.ts | 60 +++++++++++++++--- tools/push3-evolution/test/mutate.test.ts | 75 +++++++++++++++++++++++ 2 files changed, 125 insertions(+), 10 deletions(-) diff --git a/tools/push3-evolution/mutate.ts b/tools/push3-evolution/mutate.ts index ac198ef..a64b10c 100644 --- a/tools/push3-evolution/mutate.ts +++ b/tools/push3-evolution/mutate.ts @@ -6,7 +6,8 @@ * 2. swapOperator — replace an arithmetic opcode with its pair (ADD↔SUB, MUL↔DIV, GT↔LT) * 3. deleteInstruction — remove a random instruction and validate * 4. insertInstruction — insert a stack-neutral sequence at a random position - * 5. crossover — single-point crossover of two programs at instruction boundaries + * 5. subtreeCrossover — sub-expression swap: graft a random (…) block from one parent into the other + * crossover — delegates to subtreeCrossover; falls back to single-point flat crossover * (meta) mutate — apply N random mutations from operators 1–4 * * All mutations validate the output via the transpiler's stack simulation. @@ -304,9 +305,53 @@ export function insertInstruction(program: Push3Program): Push3Program { } /** - * Crossover: single-point crossover of two programs at instruction boundaries. + * Sub-expression swap crossover: graft a random list sub-tree from `b` into `a`. * - * Splits `a.items[0..splitA]` with `b.items[splitB..]` and validates the result. + * Push3 `(…)` blocks are self-contained sub-expressions and form natural crossover + * points. This operator: + * 1. Collects all non-root list paths in both programs (the nested `(…)` blocks). + * 2. Randomly pairs one path from `a` with one from `b`. + * 3. Replaces `a`'s sub-tree at that path with `b`'s sub-tree. + * 4. Validates the result via the transpiler; retries up to MAX_ATTEMPTS times. + * 5. Falls back to flat single-point crossover if no valid sub-tree swap is found + * (e.g. when neither parent has nested list nodes). + * + * @param a First parent (receives the grafted sub-tree). + * @param b Second parent (sub-tree donor). + * @returns Valid child program, or `a` if no valid crossing is found. + */ +export function subtreeCrossover(a: Push3Program, b: Push3Program): Push3Program { + if (a.kind !== 'list' || b.kind !== 'list') return a; + + // Non-root list paths: the nested (…) blocks that are natural crossover points. + // Filtering out path=[] ensures we never try to wholesale replace the root program. + const listPathsA = collectListPaths(a).filter((p) => p.length > 0); + const listPathsB = collectListPaths(b).filter((p) => p.length > 0); + + if (listPathsA.length > 0 && listPathsB.length > 0) { + const MAX_ATTEMPTS = 10; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const pathA = pick(listPathsA); + const pathB = pick(listPathsB); + const subB = getAt(b, pathB); + const child = replaceAt(a, pathA, subB); + if (isValid(child)) return child; + } + } + + // Flat single-point fallback: combine a.items[0..splitA] + b.items[splitB..]. + const splitA = rand(a.items.length + 1); + const splitB = rand(b.items.length + 1); + const newItems = [...a.items.slice(0, splitA), ...b.items.slice(splitB)]; + const fallback: Node = { kind: 'list', items: newItems }; + return isValid(fallback) ? fallback : a; +} + +/** + * Crossover: combine two Push3 programs via sub-expression swap (primary) or + * flat single-point crossover (fallback when no valid nested-list swap is found). + * + * Delegates to subtreeCrossover, which swaps random `(…)` blocks between parents. * Both programs must be top-level list nodes. * * @returns Combined program, or `a` if either input is not a list or the result @@ -314,17 +359,12 @@ export function insertInstruction(program: Push3Program): Push3Program { */ export function crossover(a: Push3Program, b: Push3Program): Push3Program { if (a.kind !== 'list') { - const validated = isValid(a) ? a : { kind: 'list', items: [] } as Node; + const validated = isValid(a) ? a : ({ kind: 'list', items: [] } as Node); return validated; } if (b.kind !== 'list') return a; - const splitA = rand(a.items.length + 1); - const splitB = rand(b.items.length + 1); - - const newItems = [...a.items.slice(0, splitA), ...b.items.slice(splitB)]; - const mutated: Node = { kind: 'list', items: newItems }; - return isValid(mutated) ? mutated : a; + return subtreeCrossover(a, b); } /** diff --git a/tools/push3-evolution/test/mutate.test.ts b/tools/push3-evolution/test/mutate.test.ts index 86d92b3..64fc344 100644 --- a/tools/push3-evolution/test/mutate.test.ts +++ b/tools/push3-evolution/test/mutate.test.ts @@ -7,6 +7,7 @@ import { swapOperator, deleteInstruction, insertInstruction, + subtreeCrossover, crossover, mutate, isValid, @@ -236,6 +237,78 @@ describe('insertInstruction', () => { }); }); +// --------------------------------------------------------------------------- +// subtreeCrossover +// --------------------------------------------------------------------------- + +describe('subtreeCrossover', () => { + it('produces a valid program from two programs with nested list blocks', () => { + const child = subtreeCrossover(WITH_IF, WITH_IF); + expect(isValid(child)).toBe(true); + }); + + it('falls back to flat crossover when programs have no nested lists', () => { + // FOUR_OUT is a flat list with no (…) sub-expressions. + const child = subtreeCrossover(FOUR_OUT, FOUR_OUT); + expect(isValid(child)).toBe(true); + }); + + it('returns `a` when `b` is not a list', () => { + const notList: Push3Program = { kind: 'int', value: 42n }; + const child = subtreeCrossover(FOUR_OUT, notList); + expect(serialize(child)).toBe(serialize(FOUR_OUT)); + }); + + it('returns `a` when `a` is not a list', () => { + const notList: Push3Program = { kind: 'int', value: 99n }; + const child = subtreeCrossover(notList, FOUR_OUT); + expect(isValid(child)).toBe(false); // notList is an int, not valid on its own + expect(child).toBe(notList); // returns `a` unchanged + }); + + it('produces a valid program from two optimizer programs', () => { + const child = subtreeCrossover(optimizer, optimizer); + expect(isValid(child)).toBe(true); + }); + + it('can swap a list sub-expression from parent b into parent a', () => { + // Two programs differing only in their EXEC.IF true-branch constant. + // Swapping the true branch from B (contains 99) into A (contains 10) must + // produce a child different from A at least once in 30 trials. + const parentA = parse( + '( DYADIC.DUP 91000000000000000000 DYADIC.> EXEC.IF' + + ' ( 10 DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )' + + ' ( DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP ) )', + ); + const parentB = parse( + '( DYADIC.DUP 91000000000000000000 DYADIC.> EXEC.IF' + + ' ( 99 DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )' + + ' ( DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP ) )', + ); + let foundDifferent = false; + for (let i = 0; i < 30; i++) { + const child = subtreeCrossover(parentA, parentB); + expect(isValid(child)).toBe(true); + if (serialize(child) !== serialize(parentA)) { + foundDifferent = true; + break; + } + } + expect(foundDifferent).toBe(true); + }); + + it('produces diverse offspring across multiple calls on programs with many sub-expressions', () => { + const seen = new Set(); + for (let i = 0; i < 20; i++) { + const child = subtreeCrossover(optimizer, optimizer); + expect(isValid(child)).toBe(true); + seen.add(serialize(child)); + } + // The optimizer has many nested (…) blocks; expect multiple distinct offspring. + expect(seen.size).toBeGreaterThanOrEqual(2); + }); +}); + // --------------------------------------------------------------------------- // crossover // --------------------------------------------------------------------------- @@ -319,6 +392,7 @@ describe('edge cases', () => { expect(isValid(swapOperator(EMPTY))).toBe(true); expect(isValid(deleteInstruction(EMPTY))).toBe(true); expect(isValid(insertInstruction(EMPTY))).toBe(true); + expect(isValid(subtreeCrossover(EMPTY, EMPTY))).toBe(true); expect(isValid(crossover(EMPTY, EMPTY))).toBe(true); expect(isValid(mutate(EMPTY, 3))).toBe(true); }); @@ -328,6 +402,7 @@ describe('edge cases', () => { expect(isValid(swapOperator(SINGLE_POP))).toBe(true); expect(isValid(deleteInstruction(SINGLE_POP))).toBe(true); expect(isValid(insertInstruction(SINGLE_POP))).toBe(true); + expect(isValid(subtreeCrossover(SINGLE_POP, SINGLE_POP))).toBe(true); expect(isValid(crossover(SINGLE_POP, SINGLE_POP))).toBe(true); expect(isValid(mutate(SINGLE_POP, 3))).toBe(true); });