Merge pull request 'fix: Push3 evolution: mutation operators for bytecode programs (#544)' (#583) from fix/issue-544 into master
This commit is contained in:
commit
f19ff8846f
5 changed files with 2363 additions and 0 deletions
363
tools/push3-evolution/mutate.ts
Normal file
363
tools/push3-evolution/mutate.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
/**
|
||||
* Push3 mutation operators for optimizer evolution.
|
||||
*
|
||||
* Implements five mutation operators on Push3 AST programs plus one meta-operator:
|
||||
* 1. mutateConstant — shift a random integer literal by ±δ
|
||||
* 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
|
||||
* (meta) mutate — apply N random mutations from operators 1–4
|
||||
*
|
||||
* All mutations validate the output via the transpiler's stack simulation.
|
||||
* Invalid mutations silently return the original program.
|
||||
*/
|
||||
|
||||
import { Node } from '../push3-transpiler/src/parser';
|
||||
import { transpile } from '../push3-transpiler/src/transpiler';
|
||||
|
||||
export type Push3Program = Node;
|
||||
|
||||
// ---- Swap map: arithmetic and comparison operator pairs ----
|
||||
|
||||
const SWAP_PAIRS: Array<[string, string]> = [
|
||||
['DYADIC.+', 'DYADIC.-'],
|
||||
['DYADIC.-', 'DYADIC.+'],
|
||||
['DYADIC.*', 'DYADIC./'],
|
||||
['DYADIC./', 'DYADIC.*'],
|
||||
['DYADIC.>', 'DYADIC.<'],
|
||||
['DYADIC.<', 'DYADIC.>'],
|
||||
['DYADIC.>=', 'DYADIC.<='],
|
||||
['DYADIC.<=', 'DYADIC.>='],
|
||||
];
|
||||
|
||||
const SWAP_MAP = new Map<string, string>(SWAP_PAIRS);
|
||||
|
||||
// ---- Random helpers ----
|
||||
|
||||
function rand(n: number): number {
|
||||
return Math.floor(Math.random() * n);
|
||||
}
|
||||
|
||||
function pick<T>(arr: T[]): T {
|
||||
return arr[rand(arr.length)];
|
||||
}
|
||||
|
||||
// ---- Immutable tree navigation and update ----
|
||||
|
||||
/**
|
||||
* Navigate to the node at the given path (sequence of list indices from root).
|
||||
*/
|
||||
export function getAt(root: Node, path: number[]): Node {
|
||||
let cur = root;
|
||||
for (const idx of path) {
|
||||
if (cur.kind !== 'list') throw new Error('getAt: not a list at path step');
|
||||
cur = cur.items[idx];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new tree with the node at `path` replaced by `newNode`.
|
||||
* Structurally shares all unchanged subtrees.
|
||||
*/
|
||||
export function replaceAt(root: Node, path: number[], newNode: Node): Node {
|
||||
if (path.length === 0) return newNode;
|
||||
if (root.kind !== 'list') throw new Error('replaceAt: not a list at path step');
|
||||
const [head, ...rest] = path;
|
||||
const newItems = root.items.map((item, i) =>
|
||||
i === head ? replaceAt(item, rest, newNode) : item,
|
||||
);
|
||||
return { kind: 'list', items: newItems };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new tree with the item at `index` removed from the list at `parentPath`.
|
||||
*/
|
||||
function deleteAt(root: Node, parentPath: number[], index: number): Node {
|
||||
const parent = getAt(root, parentPath);
|
||||
if (parent.kind !== 'list') throw new Error('deleteAt: parent is not a list');
|
||||
const newParent: Node = {
|
||||
kind: 'list',
|
||||
items: parent.items.filter((_, i) => i !== index),
|
||||
};
|
||||
return replaceAt(root, parentPath, newParent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new tree with `inserted` spliced in at `index` within the list at `parentPath`.
|
||||
*/
|
||||
function insertAt(
|
||||
root: Node,
|
||||
parentPath: number[],
|
||||
index: number,
|
||||
inserted: Node[],
|
||||
): Node {
|
||||
const parent = getAt(root, parentPath);
|
||||
if (parent.kind !== 'list') throw new Error('insertAt: parent is not a list');
|
||||
const items = [...parent.items];
|
||||
items.splice(index, 0, ...inserted);
|
||||
const newParent: Node = { kind: 'list', items };
|
||||
return replaceAt(root, parentPath, newParent);
|
||||
}
|
||||
|
||||
// ---- Node collectors ----
|
||||
|
||||
/**
|
||||
* Collect the path (root-to-node index sequence) of every node matching `test`.
|
||||
*/
|
||||
function collectPaths(
|
||||
root: Node,
|
||||
test: (n: Node) => boolean,
|
||||
prefix: number[] = [],
|
||||
): number[][] {
|
||||
const results: number[][] = [];
|
||||
if (test(root)) results.push(prefix);
|
||||
if (root.kind === 'list') {
|
||||
for (let i = 0; i < root.items.length; i++) {
|
||||
results.push(...collectPaths(root.items[i], test, [...prefix, i]));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect {parentPath, index} for list items matching `test`.
|
||||
* `parentPath` leads to the containing list; `index` is the item's position in it.
|
||||
*/
|
||||
function collectListPositions(
|
||||
root: Node,
|
||||
test: (item: Node, parentItems: Node[], index: number) => boolean,
|
||||
currentPath: number[] = [],
|
||||
): Array<{ parentPath: number[]; index: number }> {
|
||||
const results: Array<{ parentPath: number[]; index: number }> = [];
|
||||
if (root.kind === 'list') {
|
||||
for (let i = 0; i < root.items.length; i++) {
|
||||
if (test(root.items[i], root.items, i)) {
|
||||
results.push({ parentPath: currentPath, index: i });
|
||||
}
|
||||
results.push(
|
||||
...collectListPositions(root.items[i], test, [...currentPath, i]),
|
||||
);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the paths to every list node (including the root list itself).
|
||||
*/
|
||||
function collectListPaths(root: Node, prefix: number[] = []): number[][] {
|
||||
const results: number[][] = [];
|
||||
if (root.kind === 'list') {
|
||||
results.push(prefix);
|
||||
for (let i = 0; i < root.items.length; i++) {
|
||||
results.push(...collectListPaths(root.items[i], [...prefix, i]));
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---- Validation ----
|
||||
|
||||
/**
|
||||
* Return true if the program is structurally and stack-semantically valid.
|
||||
* Uses the transpiler's symbolic stack simulation for validation.
|
||||
*/
|
||||
export function isValid(program: Push3Program): boolean {
|
||||
try {
|
||||
transpile(program);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// Re-throw non-Error values (e.g. thrown primitives) to surface real bugs.
|
||||
// All transpiler errors are proper Error instances (stack underflow, unknown
|
||||
// instruction, etc.), so only those are caught and treated as invalid programs.
|
||||
if (!(e instanceof Error)) throw e;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Serialiser (useful for testing / deduplication) ----
|
||||
|
||||
/**
|
||||
* Serialise a Push3Program back to source text (round-trips through parse()).
|
||||
*/
|
||||
export function serialize(program: Push3Program): string {
|
||||
switch (program.kind) {
|
||||
case 'int':
|
||||
return program.value.toString();
|
||||
case 'bool':
|
||||
return program.value ? 'TRUE' : 'FALSE';
|
||||
case 'instr':
|
||||
return program.name;
|
||||
case 'name':
|
||||
return program.text;
|
||||
case 'list':
|
||||
return `( ${program.items.map(serialize).join(' ')} )`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Mutation operators ----
|
||||
|
||||
/**
|
||||
* Constant perturbation: shift a randomly chosen integer literal by `delta`.
|
||||
*
|
||||
* @param program Source program (not mutated in-place).
|
||||
* @param delta Amount to add to the chosen constant (may be negative).
|
||||
* @returns Mutated program, or the original if no int nodes exist.
|
||||
*/
|
||||
export function mutateConstant(
|
||||
program: Push3Program,
|
||||
delta: number,
|
||||
): Push3Program {
|
||||
const paths = collectPaths(program, (n) => n.kind === 'int');
|
||||
if (paths.length === 0) return program;
|
||||
|
||||
const path = pick(paths);
|
||||
const node = getAt(program, path);
|
||||
if (node.kind !== 'int') return program;
|
||||
|
||||
const newValue = node.value + BigInt(delta);
|
||||
const clampedValue = newValue < 0n ? 0n : newValue;
|
||||
const newNode: Node = { kind: 'int', value: clampedValue };
|
||||
const mutated = replaceAt(program, path, newNode);
|
||||
return isValid(mutated) ? mutated : program;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator swap: replace a randomly chosen arithmetic or comparison opcode with
|
||||
* its pair (ADD↔SUB, MUL↔DIV, GT↔LT, GTE↔LTE).
|
||||
*
|
||||
* Stack depth is preserved because all swap pairs have identical stack effects.
|
||||
*
|
||||
* @returns Mutated program, or the original if no swappable ops exist.
|
||||
*/
|
||||
export function swapOperator(program: Push3Program): Push3Program {
|
||||
const paths = collectPaths(
|
||||
program,
|
||||
(n) => n.kind === 'instr' && SWAP_MAP.has(n.name),
|
||||
);
|
||||
if (paths.length === 0) return program;
|
||||
|
||||
const path = pick(paths);
|
||||
const node = getAt(program, path);
|
||||
if (node.kind !== 'instr') return program;
|
||||
|
||||
const newName = SWAP_MAP.get(node.name)!;
|
||||
const mutated = replaceAt(program, path, { kind: 'instr', name: newName });
|
||||
return isValid(mutated) ? mutated : program;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instruction deletion: remove a random non-EXEC.IF instruction and validate.
|
||||
*
|
||||
* Rejects mutations that produce invalid stack state (validation via transpiler).
|
||||
*
|
||||
* @returns Mutated program, or the original if deletion produces an invalid program.
|
||||
*/
|
||||
export function deleteInstruction(program: Push3Program): Push3Program {
|
||||
// Collect only instr nodes (never EXEC.IF itself — that would orphan its branches).
|
||||
// int/bool/name/list nodes are deliberately excluded to preserve program structure.
|
||||
const positions = collectListPositions(
|
||||
program,
|
||||
(item) => item.kind === 'instr' && item.name !== 'EXEC.IF',
|
||||
);
|
||||
if (positions.length === 0) return program;
|
||||
|
||||
const { parentPath, index } = pick(positions);
|
||||
const mutated = deleteAt(program, parentPath, index);
|
||||
return isValid(mutated) ? mutated : program;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instruction insertion: insert a stack-neutral sequence (push 0 + POP) at a
|
||||
* random position within a random list in the program.
|
||||
*
|
||||
* The inserted sequence has no net effect on stack depth:
|
||||
* DYADIC stack before: [..., X]
|
||||
* After `0`: [..., X, 0]
|
||||
* After `DYADIC.POP`: [..., X] (neutral)
|
||||
*
|
||||
* Rejects insertions that produce invalid stack state.
|
||||
*
|
||||
* @returns Mutated program, or the original if the insertion is invalid.
|
||||
*/
|
||||
export function insertInstruction(program: Push3Program): Push3Program {
|
||||
const listPaths = collectListPaths(program);
|
||||
if (listPaths.length === 0) return program;
|
||||
|
||||
const listPath = pick(listPaths);
|
||||
const listNode = getAt(program, listPath);
|
||||
if (listNode.kind !== 'list') return program;
|
||||
|
||||
// Random insertion index within the chosen list (including append at end)
|
||||
const insertionIndex = rand(listNode.items.length + 1);
|
||||
|
||||
// Stack-neutral pair: push integer 0, then discard it
|
||||
const neutralPair: Node[] = [
|
||||
{ kind: 'int', value: 0n },
|
||||
{ kind: 'instr', name: 'DYADIC.POP' },
|
||||
];
|
||||
|
||||
const mutated = insertAt(program, listPath, insertionIndex, neutralPair);
|
||||
return isValid(mutated) ? mutated : program;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crossover: single-point crossover of two programs at instruction boundaries.
|
||||
*
|
||||
* Splits `a.items[0..splitA]` with `b.items[splitB..]` and validates the result.
|
||||
* Both programs must be top-level list nodes.
|
||||
*
|
||||
* @returns Combined program, or `a` if either input is not a list or the result
|
||||
* fails validation.
|
||||
*/
|
||||
export function crossover(a: Push3Program, b: Push3Program): Push3Program {
|
||||
if (a.kind !== 'list') {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `rate` random mutations from the four single-program operators.
|
||||
*
|
||||
* Each mutation is applied to the result of the previous one.
|
||||
* Mutations that produce invalid programs are skipped (program unchanged for
|
||||
* that step), so the returned program is always valid.
|
||||
*
|
||||
* @param program Input program.
|
||||
* @param rate Number of mutations to attempt.
|
||||
* @returns Mutated program (always valid when given a valid input).
|
||||
*/
|
||||
export function mutate(program: Push3Program, rate: number): Push3Program {
|
||||
let current = program;
|
||||
for (let i = 0; i < rate; i++) {
|
||||
switch (rand(4)) {
|
||||
case 0:
|
||||
current = mutateConstant(
|
||||
current,
|
||||
(rand(2) === 0 ? 1 : -1) * (rand(10) + 1),
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
current = swapOperator(current);
|
||||
break;
|
||||
case 2:
|
||||
current = deleteInstruction(current);
|
||||
break;
|
||||
case 3:
|
||||
current = insertInstruction(current);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
1620
tools/push3-evolution/package-lock.json
generated
Normal file
1620
tools/push3-evolution/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
14
tools/push3-evolution/package.json
Normal file
14
tools/push3-evolution/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "push3-evolution",
|
||||
"version": "1.0.0",
|
||||
"description": "Push3 mutation operators for optimizer evolution",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"build": "tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
352
tools/push3-evolution/test/mutate.test.ts
Normal file
352
tools/push3-evolution/test/mutate.test.ts
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { parse } from '../../push3-transpiler/src/parser';
|
||||
import {
|
||||
mutateConstant,
|
||||
swapOperator,
|
||||
deleteInstruction,
|
||||
insertInstruction,
|
||||
crossover,
|
||||
mutate,
|
||||
isValid,
|
||||
serialize,
|
||||
getAt,
|
||||
replaceAt,
|
||||
} from '../mutate';
|
||||
import type { Push3Program } from '../mutate';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test programs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A minimal valid program that leaves 4 values on the DYADIC stack.
|
||||
// The stack starts with 8 inputs; we pop 4 (arithmetic), leaving 4 on the stack.
|
||||
// ( a b c d ) → stack after: inputs[4..7] + result on each step
|
||||
// Simplest: discard top 4 inputs via POP, leaving the bottom 4.
|
||||
const FOUR_OUT = parse(
|
||||
'( DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )',
|
||||
);
|
||||
|
||||
// Program with arithmetic operators and constants — useful for swapOperator/mutateConstant.
|
||||
// Leaves 4 values on stack: uses 4 inputs (slots 4-7 remain as outputs).
|
||||
const WITH_ARITH = parse(
|
||||
'( 50 DYADIC.POP 100 DYADIC.+ DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )',
|
||||
);
|
||||
|
||||
// Program with a comparison → EXEC.IF block, leaving 4 outputs.
|
||||
const WITH_IF = parse(
|
||||
'( DYADIC.DUP 91000000000000000000 DYADIC.> EXEC.IF ( DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP ) ( DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP ) )',
|
||||
);
|
||||
|
||||
// Empty program — stack has 8 inputs, transpile pops 4 with defaults.
|
||||
const EMPTY = parse('( )');
|
||||
|
||||
// Single-instruction program.
|
||||
const SINGLE_POP = parse('( DYADIC.POP )');
|
||||
|
||||
// Load the real optimizer_v3 program.
|
||||
let optimizer: Push3Program;
|
||||
try {
|
||||
const src = readFileSync(
|
||||
join(__dirname, '../../push3-transpiler/optimizer_v3.push3'),
|
||||
'utf-8',
|
||||
);
|
||||
optimizer = parse(src);
|
||||
} catch {
|
||||
// Fall back to WITH_ARITH if file is unavailable.
|
||||
optimizer = WITH_ARITH;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isValid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isValid', () => {
|
||||
it('returns true for a valid program', () => {
|
||||
expect(isValid(FOUR_OUT)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for an empty program (inputs remain on stack)', () => {
|
||||
expect(isValid(EMPTY)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a non-list node', () => {
|
||||
const notList: Push3Program = { kind: 'int', value: 42n };
|
||||
expect(isValid(notList)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a program with stack underflow', () => {
|
||||
// Tries to pop 9 times from a stack that only has 8 inputs.
|
||||
const underflow = parse(
|
||||
'( DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )',
|
||||
);
|
||||
expect(isValid(underflow)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a program with unknown instruction', () => {
|
||||
// Inject an invalid instr node directly.
|
||||
const bad: Push3Program = {
|
||||
kind: 'list',
|
||||
items: [{ kind: 'instr', name: 'DYADIC.NONEXISTENT' }],
|
||||
};
|
||||
expect(isValid(bad)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// serialize / round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('serialize', () => {
|
||||
it('round-trips an integer literal', () => {
|
||||
const p = parse('( 12345 DYADIC.POP )');
|
||||
expect(serialize(p)).toBe('( 12345 DYADIC.POP )');
|
||||
});
|
||||
|
||||
it('round-trips boolean literals', () => {
|
||||
const p = parse('( TRUE FALSE )');
|
||||
expect(serialize(p)).toBe('( TRUE FALSE )');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mutateConstant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('mutateConstant', () => {
|
||||
it('shifts a constant by the given delta', () => {
|
||||
const prog = parse('( 50 DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )');
|
||||
const mutated = mutateConstant(prog, 5);
|
||||
// The program should change (the constant 50 should become 55).
|
||||
expect(serialize(mutated)).not.toBe(serialize(prog));
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
});
|
||||
|
||||
it('clamps to 0 when delta would make constant negative', () => {
|
||||
const prog = parse('( 3 DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )');
|
||||
const mutated = mutateConstant(prog, -100);
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
// Value should be clamped to 0, not negative.
|
||||
const s = serialize(mutated);
|
||||
expect(s).not.toContain('-');
|
||||
});
|
||||
|
||||
it('returns original when there are no integer literals', () => {
|
||||
const noInts = parse('( DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )');
|
||||
const mutated = mutateConstant(noInts, 10);
|
||||
expect(serialize(mutated)).toBe(serialize(noInts));
|
||||
});
|
||||
|
||||
it('produces a valid program on optimizer_v3', () => {
|
||||
const mutated = mutateConstant(optimizer, 7);
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// swapOperator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('swapOperator', () => {
|
||||
it('swaps ADD to SUB or vice versa', () => {
|
||||
const prog = parse('( 10 20 DYADIC.+ DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )');
|
||||
const mutated = swapOperator(prog);
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
// Should have changed (DYADIC.+ → DYADIC.-)
|
||||
expect(serialize(mutated)).toContain('DYADIC.-');
|
||||
});
|
||||
|
||||
it('swaps GT to LT', () => {
|
||||
const prog = parse('( DYADIC.DUP 50 DYADIC.> DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )');
|
||||
const mutated = swapOperator(prog);
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns original when no swappable operators exist', () => {
|
||||
const noSwap = parse('( DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )');
|
||||
const mutated = swapOperator(noSwap);
|
||||
expect(serialize(mutated)).toBe(serialize(noSwap));
|
||||
});
|
||||
|
||||
it('produces a valid program on optimizer_v3', () => {
|
||||
const mutated = swapOperator(optimizer);
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deleteInstruction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('deleteInstruction', () => {
|
||||
it('produces a shorter or equal-length program', () => {
|
||||
const prog = parse('( DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.DUP DYADIC.POP )');
|
||||
const mutated = deleteInstruction(prog);
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
// Either deleted one instruction or returned original (if delete broke validity)
|
||||
const origLen = serialize(prog).length;
|
||||
const mutLen = serialize(mutated).length;
|
||||
expect(mutLen).toBeLessThanOrEqual(origLen);
|
||||
});
|
||||
|
||||
it('returns original for a program with no deletable instructions', () => {
|
||||
// A program with only EXEC.IF and its branches — no stand-alone non-EXEC.IF instrs
|
||||
// at a directly deletable position without breaking structure.
|
||||
const noDelete: Push3Program = {
|
||||
kind: 'list',
|
||||
items: [],
|
||||
};
|
||||
const mutated = deleteInstruction(noDelete);
|
||||
expect(serialize(mutated)).toBe(serialize(noDelete));
|
||||
});
|
||||
|
||||
it('produces a valid program on optimizer_v3', () => {
|
||||
const mutated = deleteInstruction(optimizer);
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles single-instruction programs', () => {
|
||||
// Deleting the only instruction may leave an empty but valid program.
|
||||
const mutated = deleteInstruction(SINGLE_POP);
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// insertInstruction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('insertInstruction', () => {
|
||||
it('produces a longer program', () => {
|
||||
const mutated = insertInstruction(FOUR_OUT);
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
// Should have grown by 2 tokens (push 0 + DYADIC.POP)
|
||||
expect(serialize(mutated).length).toBeGreaterThan(serialize(FOUR_OUT).length);
|
||||
});
|
||||
|
||||
it('produces a valid program on optimizer_v3', () => {
|
||||
const mutated = insertInstruction(optimizer);
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles an empty program', () => {
|
||||
const mutated = insertInstruction(EMPTY);
|
||||
expect(isValid(mutated)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// crossover
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('crossover', () => {
|
||||
it('produces a valid combined program from two valid parents', () => {
|
||||
const a = FOUR_OUT;
|
||||
const b = parse('( DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )');
|
||||
// crossover may return a or fallback if combined is invalid — that is correct behaviour
|
||||
const child = crossover(a, b);
|
||||
expect(isValid(child)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns `a` when `a` is not a list', () => {
|
||||
const notList: Push3Program = { kind: 'list', items: [] };
|
||||
const b = FOUR_OUT;
|
||||
const child = crossover(notList, b);
|
||||
expect(isValid(child)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns `a` when combined result is invalid', () => {
|
||||
// It's hard to guarantee invalidity without controlling the split points,
|
||||
// so we just verify the return is always valid.
|
||||
const child = crossover(FOUR_OUT, WITH_ARITH);
|
||||
expect(isValid(child)).toBe(true);
|
||||
});
|
||||
|
||||
it('produces a valid program with two optimizer programs', () => {
|
||||
const seed = parse('( DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )');
|
||||
const child = crossover(optimizer, seed);
|
||||
expect(isValid(child)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mutate (meta-operator)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('mutate', () => {
|
||||
it('returns a valid program with rate=0 (identity)', () => {
|
||||
const result = mutate(FOUR_OUT, 0);
|
||||
expect(isValid(result)).toBe(true);
|
||||
expect(serialize(result)).toBe(serialize(FOUR_OUT));
|
||||
});
|
||||
|
||||
it('returns a valid program with rate=1', () => {
|
||||
const result = mutate(optimizer, 1);
|
||||
expect(isValid(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a valid program with rate=3', () => {
|
||||
const result = mutate(optimizer, 3);
|
||||
expect(isValid(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a valid program with rate=10 (max stress)', () => {
|
||||
const result = mutate(optimizer, 10);
|
||||
expect(isValid(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('mutate(optimizer_v3, 3) produces at least 10 distinct valid variants', () => {
|
||||
const seen = new Set<string>();
|
||||
const TRIALS = 20;
|
||||
for (let i = 0; i < TRIALS; i++) {
|
||||
const variant = mutate(optimizer, 3);
|
||||
expect(isValid(variant)).toBe(true);
|
||||
seen.add(serialize(variant));
|
||||
}
|
||||
// With 20 trials, expect at least 10 distinct results (mutations are randomized).
|
||||
expect(seen.size).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('all operators handle an empty program gracefully', () => {
|
||||
expect(isValid(mutateConstant(EMPTY, 5))).toBe(true);
|
||||
expect(isValid(swapOperator(EMPTY))).toBe(true);
|
||||
expect(isValid(deleteInstruction(EMPTY))).toBe(true);
|
||||
expect(isValid(insertInstruction(EMPTY))).toBe(true);
|
||||
expect(isValid(crossover(EMPTY, EMPTY))).toBe(true);
|
||||
expect(isValid(mutate(EMPTY, 3))).toBe(true);
|
||||
});
|
||||
|
||||
it('all operators handle a single-instruction program gracefully', () => {
|
||||
expect(isValid(mutateConstant(SINGLE_POP, 1))).toBe(true);
|
||||
expect(isValid(swapOperator(SINGLE_POP))).toBe(true);
|
||||
expect(isValid(deleteInstruction(SINGLE_POP))).toBe(true);
|
||||
expect(isValid(insertInstruction(SINGLE_POP))).toBe(true);
|
||||
expect(isValid(crossover(SINGLE_POP, SINGLE_POP))).toBe(true);
|
||||
expect(isValid(mutate(SINGLE_POP, 3))).toBe(true);
|
||||
});
|
||||
|
||||
it('maintains validity across deep stack programs', () => {
|
||||
// Push 7 extra values then do arithmetic — exercises deeper stack paths.
|
||||
const deep = parse(
|
||||
'( 1 2 3 4 5 6 7 DYADIC.+ DYADIC.+ DYADIC.+ DYADIC.+ DYADIC.+ DYADIC.+ DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )',
|
||||
);
|
||||
expect(isValid(deep)).toBe(true);
|
||||
expect(isValid(mutate(deep, 5))).toBe(true);
|
||||
});
|
||||
|
||||
it('getAt and replaceAt are inverse operations', () => {
|
||||
const prog = parse('( 10 20 DYADIC.+ DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP DYADIC.POP )');
|
||||
const path = [0]; // first item: int 10
|
||||
const node = getAt(prog, path);
|
||||
expect(node.kind).toBe('int');
|
||||
const restored = replaceAt(prog, path, node);
|
||||
expect(serialize(restored)).toBe(serialize(prog));
|
||||
});
|
||||
});
|
||||
14
tools/push3-evolution/tsconfig.json
Normal file
14
tools/push3-evolution/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue