Merge pull request 'fix: Push3 evolution: mutation operators for bytecode programs (#544)' (#583) from fix/issue-544 into master

This commit is contained in:
johba 2026-03-11 17:54:37 +01:00
commit f19ff8846f
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"]
}