fix: feat: Push3 evolution — crossover operator (#639)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-13 05:54:48 +00:00
parent 709dfccf7e
commit f8b765a9f8
2 changed files with 125 additions and 10 deletions

View file

@ -6,7 +6,8 @@
* 2. swapOperator replace an arithmetic opcode with its pair (ADDSUB, MULDIV, GTLT)
* 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 14
*
* 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);
}
/**

View file

@ -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<string>();
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);
});