UNPKG

gp-lite

Version:

Tiny, zero-dependency GA/GP engine for TypeScript/JS with first-class types, deterministic RNG, and budget-aware runs.

611 lines (595 loc) 24.6 kB
/** * Problem definition for the genome type `T`. * Provide domain-specific implementations of initialization, fitness, and operators. */ interface GPAsyncContext { /** Optional AbortSignal to cancel long-running work */ signal?: AbortSignal; } interface GPProblem<T> { /** Create a fresh random genome. */ createRandom(rng: RNG, ctx?: GPAsyncContext): MaybePromise<T>; /** Fitness score: higher is better. Must be finite. */ fitness(g: Readonly<T>, ctx?: GPAsyncContext): MaybePromise<number>; /** Return a mutated copy of the genome. */ mutate(g: Readonly<T>, rng: RNG, ctx?: GPAsyncContext): MaybePromise<T>; /** Return crossover children from parents `a` and `b`. */ crossover(a: Readonly<T>, b: Readonly<T>, rng: RNG, ctx?: GPAsyncContext): MaybePromise<T[]>; /** Validate genome; invalid genomes are assigned -Infinity fitness. */ isValid?(g: Readonly<T>, ctx?: GPAsyncContext): MaybePromise<boolean>; /** Repair invalid genome before fitness evaluation. */ repair?(g: Readonly<T>, rng: RNG, ctx?: GPAsyncContext): MaybePromise<T>; /** Optional distance function for diversity/novelty metrics. */ distance?(a: Readonly<T>, b: Readonly<T>, ctx?: GPAsyncContext): MaybePromise<number>; /** Optional unit tests to validate the fitness function. */ unitTests?: Array<{ /** Test name/description */ name: string; /** Input genome to test */ genome: T; /** Test function: return true for pass, false/string for fail */ test: (fitness: number) => boolean | string; /** Expected fitness constraints */ expect: { /** Expected exact fitness value */ exact?: number; /** Minimum allowed fitness value */ min?: number; /** Maximum allowed fitness value */ max?: number; /** Fitness must be greater than this value */ greaterThan?: number; /** Fitness must be less than this value */ lessThan?: number; }; }>; } /** Utility union to allow async without breaking sync APIs */ type MaybePromise<T> = T | Promise<T>; interface RNG { /** 0 ≤ value < 1 */ next(): number; /** Integer in [0, max) */ int(max: number): number; } interface GPConfig<T = unknown> { /** Population size (default 100) */ popSize?: number; /** Max generations (default 1000) */ generations?: number; /** Optional fixed RNG seed (used if `rng` not provided) */ seed?: number; /** Elites preserved per generation (default ~2% of pop) */ elite?: number; /** Crossover probability (default 0.8) */ cxProb?: number; /** Mutation probability. If omitted, defaults to 1 - cxProb. */ mutProb?: number; /** Number of children produced by crossover (default 1) */ crossoverChildren?: number; /** Fraction of immigrants per generation (default 0.02) */ immigration?: number; /** Tournament size (default 3) */ tournament?: number; /** Early-stop if no improvement across this window (default 50) */ stall?: number; /** Stop once best fitness reaches or exceeds this value (default Infinity) */ targetFitness?: number; /** * Auto-stop if runtime exceeds this many milliseconds (default: Infinity) * @deprecated Prefer `maxWallMs`. This field remains as a back-compat alias. */ timeLimitMs?: number; /** Preferred wall-clock budget (ms). If set, overrides `timeLimitMs`. */ maxWallMs?: number; /** Max total fitness evaluations budget. */ maxEvaluations?: number; /** * Max number of in-flight genome creations/evaluations when using GPLiteAsync. * Defaults to 1 for determinism. Ignored by the synchronous engine. */ concurrency?: number; /** Optional AbortSignal to cancel an in-progress async run */ signal?: AbortSignal; /** Per-evaluation timeout for async fitness (ms). Default: Infinity (no timeout). */ fitnessTimeoutMs?: number; /** Custom RNG implementation */ rng?: RNG; /** Custom selection operator */ selector?: Selector<T>; /** Optional lifecycle hooks for generation start/end */ hooks?: GPHooks<T>; /** Optional initial population for async engine seeding */ initialPopulation?: ReadonlyArray<T>; /** * Enable memoized fitness caching. When true, previously evaluated genomes * can reuse their fitness without re-calling `problem.fitness`. * Defaults to false. */ cacheFitness?: boolean; /** * Optional key function for fitness caching. If provided, a Map keyed by the * returned string is used. If omitted and `cacheFitness` is true, a WeakMap * keyed by object identity is used for object genomes; primitive genomes are * not cached. */ fitnessKey?: (g: Readonly<T>) => string; /** * Custom genome clone function to enforce immutability between generations. * If not provided, a safe default based on structuredClone with fallbacks is used. */ clone?: (g: Readonly<T>) => T; } /** Independent string union for early-stop reasons (public) */ type StopReason = "target" | "stall" | "time" | "evaluations" | "generations" | "aborted"; /** Primary generation statistics passed to callbacks */ interface GenerationInfo<T = unknown> { /** Zero-based generation index */ generation: number; /** Best fitness observed this generation */ bestFitness: number; /** Average fitness of finite individuals this generation (or -Infinity) */ avgFitness: number; /** Number of individuals in the population */ popSize: number; /** Number of invalid individuals this generation */ invalidCount: number; /** Share of valid (finite) individuals in [0..1] */ validShare: number; /** Best genome achieving `bestFitness` */ bestGenome: T; /** Elapsed wall-clock time in ms */ elapsedMs: number; /** Reason for stopping the run if this is the final generation */ stopReason?: StopReason; /** * Per-generation timing snapshot for observability. Values are per-gen maxima * (in milliseconds) except `init`, which reflects total initialization time * to build the first population and is only present at generation 0. */ stageMaxMs?: { /** Total wall time to initialize the first population (gen 0 only). */ init?: number; /** Longest single fitness evaluation in this generation. */ evaluation?: number; /** Longest single mutation call in this generation. */ mutation?: number; /** Longest single crossover call in this generation. */ crossover?: number; }; } /** Generation callback: unified one-arg payload */ type OnGeneration<T = unknown> = (info: GenerationInfo<T>) => void; interface GPResult<T> { /** Best genome from the final population */ bestGenome: T; /** Best fitness found */ bestFitness: number; /** Number of generations actually executed */ generations: number; /** Best fitness per generation */ bestFitnessHistory: number[]; /** Best genome per generation (same order as `history`) */ bestGenomeHistory?: T[]; /** Elapsed wall-clock time in milliseconds */ elapsedMs?: number; /** Reason for stopping if early-terminated */ stopReason?: StopReason; /** Average fitness per generation, finite only (optional) */ avgFitnessHistory?: number[]; /** Count of invalid evaluations per generation (optional) */ invalidCountHistory?: number[]; /** Fraction of finite individuals per generation (0..1) */ validShareHistory?: number[]; /** Aggregated run metrics for better insight (optional) */ metrics?: GPMetrics; } /** Detailed counters and config snapshot for a GP run */ interface GPMetrics { /** Total number of fitness evaluations */ evaluations: number; /** Evaluations that resulted in -Infinity (invalid/errors/non-finite) */ invalidEvaluations: number; /** Invalid genomes successfully repaired before evaluation */ repaired: number; /** Repair attempts that still resulted in invalid genomes */ repairFailures: number; /** Fitness function threw an error */ fitnessErrors: number; /** Fitness returned NaN or ±Infinity */ nonFiniteFitness: number; /** Number of mutation operations applied */ mutations: number; /** Number of crossover operations applied */ crossovers: number; /** Number of parent selections performed */ selections: number; /** Total immigrants introduced across generations */ immigrants: number; /** Elites preserved per generation */ elitesPerGen: number; /** Config snapshot for reference */ config: { popSize: number; generations: number; cxProb: number; mutProb: number; crossoverChildren: number; immigration: number; tournament: number; stall: number; targetFitness: number; maxWallMs: number; maxEvaluations: number; }; } /** User-provided hooks to observe evolution lifecycle without coupling to storage */ interface GPHooks<T> { /** Called at the start of each generation (including generation=0) */ onGenerationStart?(info: GenerationStartInfo): void; /** Called after per-generation stats are computed */ onGenerationEnd?(info: GenerationInfo<T>): void; /** Minimal alias hook for progress per generation. */ onIteration?(info: GenerationInfo<T>): void; } type GPProgress<T = unknown> = GenerationInfo<T>; /** Named payload types for hooks (public) */ interface GenerationStartInfo { generation: number; popSize: number; elapsedMs: number; } type IterationInfo<T = unknown> = GenerationInfo<T>; /** Return index of selected parent from `pop`. */ type Selector<T> = (pop: Array<{ g: T; f: number; }>, rng: RNG) => number; /** * formatResult: Pretty-print summary of a GP run. */ declare function formatResult<T>(res: GPResult<T>): string; declare function mulberry32(seed?: number): RNG; interface CostModel { /** Estimated milliseconds per fitness evaluation (defaults to 0). */ perEvaluationMs?: number; /** Optional monetary cost per fitness evaluation (generic units). */ perEvaluationCost?: number; /** Fixed overhead per generation in milliseconds (loop bookkeeping). */ perGenerationOverheadMs?: number; /** One-time overhead per run in milliseconds (setup/teardown). */ perRunOverheadMs?: number; } interface RunEstimate { evaluations: { init: number; perGen: number; plannedTotal: number; expectedTotal: number; cappedByMaxEvaluations?: number; }; timeMs?: { init: number; perGen: number; plannedTotal: number; expectedTotal: number; cappedByMaxWall?: number; }; monetary?: { perEval: number; plannedTotal: number; expectedTotal: number; }; operations: { immigrantsPerGen: number; childrenFromBreedingPerGen: number; pairsPerGen: number; selectionsPerGen: number; expectedCrossoversPerGen: number; expectedMutationsPerGen: number; }; notes: string[]; } /** * Normalize GPConfig to mirror GPLite defaults without instantiating the engine. */ /** * Estimate planned and expected costs for a run given a config. * - Generic: uses counts of fitness evaluations + optional unit costs. * - Time and budget caps are applied to provide "capped" views. */ declare function estimateRun<T>(cfg?: GPConfig<T>, opts?: { /** If provided, overrides `cfg.generations` for expected scenario. */ expectedGenerations?: number; /** Generic cost and time units. */ units?: CostModel; }): RunEstimate; /** * Compute realized cost from a completed run's metrics (post‑hoc). */ declare function estimateFromMetrics(m: GPMetrics, units?: CostModel): { evaluations: number; timeMs: number | undefined; monetary: { total: number; perEval: number; } | undefined; }; /** * Normalize GPConfig to shared defaults without instantiating the engine. * * Guidance: * - Breeding proportions: Pc + Pm = 1 (immigration is separate). * - If mutProb is omitted, we derive it as 1 - cxProb. * - timeLimitMs is a deprecated alias of maxWallMs (kept for back-compat). */ declare function normalizeConfigBase<T>(cfg: GPConfig<T>): Required<Pick<GPConfig<T>, "popSize" | "generations" | "elite" | "cxProb" | "mutProb" | "crossoverChildren" | "immigration" | "tournament" | "stall" | "targetFitness" | "timeLimitMs" | "maxWallMs" | "maxEvaluations" | "concurrency">>; /** * GPLiten: a minimal, zero-dependency genetic programming/algorithm engine. * * - Synchronous operations only (fitness/mutate/crossover are sync) * - Deterministic RNG via `mulberry32` * - Early stopping via `targetFitness` and `stall` window * - Optional problem hooks: `isValid`, `repair` */ type EngineSyncConfig<T> = ReturnType<typeof normalizeConfigBase<T>> & { rng: RNG; selector: Selector<T>; hooks: GPHooks<T>; clone: (g: Readonly<T>) => T; cacheFitness: boolean; fitnessKey?: (g: Readonly<T>) => string; }; /** * Synchronous GP/GA engine. * * Generation lifecycle (per t): * 1) Evaluate/repair initial population (t=0) or carry `pop` from previous gen. * 2) Compute stats and invoke hooks. * 3) Build next population `next`: * - Copy `elite` best individuals unchanged (identity preserved). * - Produce breeding children to fill up to `popSize - immigrantsPerGen`: * - Allocate crossover child target from `cxProb`. * - Fill the remainder with mutation children (since Pc + Pm = 1). * - Append immigrants (sampled via createRandom) to reach `popSize`. * 4) Apply budgets/stop checks and repeat. * * Invariants: * - Pc + Pm = 1 (immigration is separate), E + M + N = λ each gen. * - Elites are copied without modification; identity is preserved across gens. * - Metrics counters reflect actual operations and evaluations. */ declare class GPLite<T> { private problem; readonly cfg: EngineSyncConfig<T>; readonly rng: RNG; private select; private fitnessCache; private problemSync; constructor(problem: GPProblem<T>, cfg?: GPConfig<T>); static seed(seed: number): RNG; private assertNotPromise; /** Guard fitness evaluation and invalid genomes. */ private safeFitness; /** * Execute the evolutionary loop (synchronous). * * Note on callbacks: `onGen` is a convenience alias to observe per-generation * progress. For most integrations, prefer the structured lifecycle hooks * provided via `new GPLite(problem, { hooks: { ... } })` — e.g. * `onGenerationStart`, `onGenerationEnd`, or `onIteration` — to keep the * problem definition pure and avoid coupling domain logic with progress I/O. */ run(onGen?: OnGeneration<T>): GPResult<T>; /** * Run unit tests defined in the problem to validate the fitness function. * Returns an array of test results. */ runUnitTests(): Array<{ name: string; genome: T; fitness: number; passed: boolean; error?: string; }>; } type EngineAsyncConfig<T> = ReturnType<typeof normalizeConfigBase<T>> & { rng: RNG; selector: Selector<T>; hooks: GPHooks<T>; clone: (g: Readonly<T>) => T; cacheFitness: boolean; fitnessKey?: (g: Readonly<T>) => string; signal?: AbortSignal; fitnessTimeoutMs: number; initialPopulation?: ReadonlyArray<T>; }; /** * Asynchronous GP/GA engine. * * Mirrors the synchronous engine but allows async `createRandom`, `fitness`, * `mutate`, and `crossover`. Concurrency controls the number of in-flight tasks. * * Key points for newcomers: * - Pc + Pm = 1 (immigration handled separately after breeding). * - Per-evaluation timeout (`fitnessTimeoutMs`) marks fitness as invalid (-Infinity). * - Budgets (`maxWallMs`, `maxEvaluations`) are respected between awaited phases. */ declare class GPLiteAsync<T> { private problem; readonly cfg: EngineAsyncConfig<T>; readonly rng: RNG; private select; private fitnessCache; constructor(problem: GPProblem<T>, cfg?: GPConfig<T>); static seed(seed: number): RNG; private safeFitness; /** * Execute the evolutionary loop (asynchronous). * * Note on callbacks: `onGen` is a convenience alias to observe per-generation * progress. For most integrations, prefer the structured lifecycle hooks via * `new GPLiteAsync(problem, { hooks: { ... } })` — e.g. `onGenerationStart`, * `onGenerationEnd`, or `onIteration` — to keep the problem definition pure * and avoid coupling domain logic with progress I/O. */ run(onGen?: OnGeneration<T>): Promise<GPResult<T>>; /** * Run unit tests defined in the problem to validate the fitness function. * Returns an array of test results. */ runUnitTests(): Promise<Array<{ name: string; genome: T; fitness: number; passed: boolean; error?: string; }>>; } /** * Tournament selection: sample `size` individuals and pick the best fitness. * Small size keeps pressure low; larger size increases selection pressure. */ declare function tournament<T>(size?: number): Selector<T>; declare class GPLiteError extends Error { readonly code?: string; constructor(message: string, code?: string); } declare class ConfigError extends GPLiteError { constructor(message: string, code?: string); } declare class ProblemError extends GPLiteError { constructor(message: string, code?: string); } declare class EvolutionError extends GPLiteError { constructor(message: string, code?: string); } /** * Validate GPConfig values and cross-field invariants. * * Highlights: * - Probabilities must be in [0,1]. * - If both cxProb and mutProb are provided, they must sum to 1 (immigration is separate). * - Elite and population sizes must be non-negative integers; elite ≤ popSize is checked at engine construction time. * - Time/evaluation budgets must be finite and ≥ 0. */ declare function validateConfig<T>(cfg: GPConfig<T>): void; declare function validateProblem<T>(problem: GPProblem<T>): void; interface ProgressSnapshot { generation: number; generationsObserved: number; totalGenerations: number; fractionByGenerations: number; stopReason?: StopReason; time: { elapsedMs: number; initMs?: number; lastGenMs?: number; perGenMsAvg?: number; estimatedTotalMs?: number; remainingMs?: number; fractionTime?: number; }; } interface ProgressTrackerOptions<T> { totalGenerations: number; onUpdate?: (p: ProgressSnapshot, info: GenerationInfo<T>) => void; } declare function createProgressTracker<T>(opts: ProgressTrackerOptions<T>): { hooks: GPHooks<T>; get(): ProgressSnapshot; reset(): void; }; interface ProgressLoggerOptions { /** Log every N generations (default 1) */ every?: number; /** Print a one-time header before the first log (default true) */ header?: boolean; /** Custom sink for log lines (default console.log) */ log?: (line: string) => void; } /** * progressLogger: tiny, zero-dep progress logger for hooks or onGen. * * Usage: * - As a hook: new GPLite(problem, { hooks: { onIteration: progressLogger() } }) * - As onGen: gp.run(progressLogger()) */ declare function progressLogger<T = unknown>(opts?: ProgressLoggerOptions): (info: GenerationInfo<T>) => void; interface CreateProblemSpec<T> { /** Fitness score: higher is better (must be finite). */ fitness: GPProblem<T>["fitness"]; /** Return a mutated copy of the genome. */ mutate: GPProblem<T>["mutate"]; /** Optional crossover; defaults to identity (returns parents). */ crossover?: GPProblem<T>["crossover"]; /** * Optional random initializer; if omitted, provide `init` and we will * generate via a few mutation steps from that seed. */ createRandom?: GPProblem<T>["createRandom"]; /** Optional deterministic seed factory used to derive createRandom. */ init?: () => T; /** Optional hooks forwarded as-is. */ isValid?: GPProblem<T>["isValid"]; repair?: GPProblem<T>["repair"]; distance?: GPProblem<T>["distance"]; } /** * createProblem: convenience to build a GPProblem with sane defaults. * * If `createRandom` is not provided, you can supply `init()` and a random * genome will be produced by applying 1..3 mutation steps from the seed. * If neither is provided, an error is thrown (we cannot generically create T). */ declare function createProblem<T>(spec: CreateProblemSpec<T>): GPProblem<T>; declare const DEFAULT_POP_SIZE = 100; declare const DEFAULT_GENERATIONS = 1000; declare const DEFAULT_ELITE: (popSize: number) => number; declare const DEFAULT_CX_PROB = 0.8; declare const DEFAULT_MUT_PROB = 0.2; declare const DEFAULT_CROSSOVER_CHILDREN = 1; declare const DEFAULT_IMMIGRATION = 0.02; declare const DEFAULT_TOURNAMENT = 3; declare const DEFAULT_STALL = 50; declare const DEFAULT_TARGET_FITNESS: number; declare const DEFAULT_MAX_WALL_MS: number; declare const DEFAULT_MAX_EVALUATIONS: number; declare const DEFAULT_CONCURRENCY = 1; declare const defaults_DEFAULT_CONCURRENCY: typeof DEFAULT_CONCURRENCY; declare const defaults_DEFAULT_CROSSOVER_CHILDREN: typeof DEFAULT_CROSSOVER_CHILDREN; declare const defaults_DEFAULT_CX_PROB: typeof DEFAULT_CX_PROB; declare const defaults_DEFAULT_ELITE: typeof DEFAULT_ELITE; declare const defaults_DEFAULT_GENERATIONS: typeof DEFAULT_GENERATIONS; declare const defaults_DEFAULT_IMMIGRATION: typeof DEFAULT_IMMIGRATION; declare const defaults_DEFAULT_MAX_EVALUATIONS: typeof DEFAULT_MAX_EVALUATIONS; declare const defaults_DEFAULT_MAX_WALL_MS: typeof DEFAULT_MAX_WALL_MS; declare const defaults_DEFAULT_MUT_PROB: typeof DEFAULT_MUT_PROB; declare const defaults_DEFAULT_POP_SIZE: typeof DEFAULT_POP_SIZE; declare const defaults_DEFAULT_STALL: typeof DEFAULT_STALL; declare const defaults_DEFAULT_TARGET_FITNESS: typeof DEFAULT_TARGET_FITNESS; declare const defaults_DEFAULT_TOURNAMENT: typeof DEFAULT_TOURNAMENT; declare namespace defaults { export { defaults_DEFAULT_CONCURRENCY as DEFAULT_CONCURRENCY, defaults_DEFAULT_CROSSOVER_CHILDREN as DEFAULT_CROSSOVER_CHILDREN, defaults_DEFAULT_CX_PROB as DEFAULT_CX_PROB, defaults_DEFAULT_ELITE as DEFAULT_ELITE, defaults_DEFAULT_GENERATIONS as DEFAULT_GENERATIONS, defaults_DEFAULT_IMMIGRATION as DEFAULT_IMMIGRATION, defaults_DEFAULT_MAX_EVALUATIONS as DEFAULT_MAX_EVALUATIONS, defaults_DEFAULT_MAX_WALL_MS as DEFAULT_MAX_WALL_MS, defaults_DEFAULT_MUT_PROB as DEFAULT_MUT_PROB, defaults_DEFAULT_POP_SIZE as DEFAULT_POP_SIZE, defaults_DEFAULT_STALL as DEFAULT_STALL, defaults_DEFAULT_TARGET_FITNESS as DEFAULT_TARGET_FITNESS, defaults_DEFAULT_TOURNAMENT as DEFAULT_TOURNAMENT }; } declare function run<T>(args: { /** Fixed initial population of candidate genomes */ initialPopulation: ReadonlyArray<T>; /** Fitness score: higher is better (must be finite) */ fitness: (g: Readonly<T>) => number; /** Return a mutated copy of the genome */ mutate: (g: Readonly<T>, rng: RNG) => T; /** Optional: create a fresh random genome (required if immigration > 0) */ createRandom?: (rng: RNG) => T; /** Optional: crossover operator for children */ crossover?: (a: Readonly<T>, b: Readonly<T>, rng: RNG) => T[]; /** Number of generations to run */ iterations: number; /** Optional: crossover probability (default 0 if no crossover given) */ cxProb?: number; /** Optional: mutation probability (default 1 for minimal API) */ mutProb?: number; /** Optional: immigrants share per generation (0..1). Requires createRandom. */ immigration?: number; }): GPResult<T>; export { ConfigError, type CostModel, EvolutionError, type GPAsyncContext, type GPConfig, type GPHooks, GPLite, GPLiteAsync, GPLiteError, type GPMetrics, type GPProblem, type GPProgress, type GPResult, type GenerationInfo, type GenerationStartInfo, type IterationInfo, type MaybePromise, type OnGeneration, ProblemError, type ProgressSnapshot, type RNG, type RunEstimate, type Selector, type StopReason, createProblem, createProgressTracker, defaults, estimateFromMetrics, estimateRun, formatResult, mulberry32, progressLogger, run, tournament, validateConfig, validateProblem };