Merge pull request 'fix: feat: Push3 evolution — crossover operator (#639)' (#657) from fix/issue-639 into master
This commit is contained in:
commit
d3de7d410a
2 changed files with 125 additions and 10 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue