fix: Push3 evolution: mutation operators for bytecode programs (#544)

Implements the five Push3 mutation operators and the meta-operator for
the optimizer evolution pipeline:

- mutateConstant: shifts a random integer literal by ±δ (clamped to 0)
- swapOperator: swaps ADD↔SUB, MUL↔DIV, GT↔LT, GTE↔LTE
- deleteInstruction: removes a random non-EXEC.IF instr; validates result
- insertInstruction: inserts stack-neutral pair (push 0 + DYADIC.POP)
- crossover: single-point crossover of two programs at instruction boundaries
- mutate: applies N random mutations from the four single-program operators

All mutations validate output via transpile() symbolic stack simulation.
Invalid mutations silently return the original program.

35 unit tests cover all operators, edge cases (empty program, single
instruction, deep stack), and the acceptance criterion that
mutate(optimizer_v3, 3) produces ≥10 distinct valid variants in 20 trials.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-11 16:24:24 +00:00
parent 02069464d4
commit a6b64d3219
5 changed files with 2363 additions and 0 deletions

View 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 (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
* (meta) mutate apply N random mutations from operators 14
*
* 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 (ADDSUB, MULDIV, GTLT, GTELTE).
*
* 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

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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));
});
});

View 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"]
}