236 lines
6.2 KiB
TypeScript
236 lines
6.2 KiB
TypeScript
/**
|
|
* NEAT Genome Implementation
|
|
*
|
|
* Represents a neural network genome with node genes and connection genes.
|
|
* Implements the core NEAT genome structure as described in the original paper.
|
|
*/
|
|
|
|
export type NodeType = 'input' | 'hidden' | 'output';
|
|
export type ActivationFunction = 'tanh' | 'sigmoid' | 'relu' | 'linear';
|
|
|
|
/**
|
|
* Node gene - represents a neuron
|
|
*/
|
|
export interface NodeGene {
|
|
id: number;
|
|
type: NodeType;
|
|
activation: ActivationFunction;
|
|
}
|
|
|
|
/**
|
|
* Connection gene - represents a synapse
|
|
*/
|
|
export interface ConnectionGene {
|
|
innovation: number;
|
|
from: number;
|
|
to: number;
|
|
weight: number;
|
|
enabled: boolean;
|
|
}
|
|
|
|
/**
|
|
* Complete genome
|
|
*/
|
|
/**
|
|
* Complete genome
|
|
*/
|
|
export interface Genome {
|
|
id: number;
|
|
nodes: NodeGene[];
|
|
connections: ConnectionGene[];
|
|
fitness: number;
|
|
}
|
|
|
|
/**
|
|
* Global innovation tracker for historical markings
|
|
*/
|
|
export class InnovationTracker {
|
|
private currentInnovation: number = 0;
|
|
private innovationHistory: Map<string, number> = new Map();
|
|
|
|
/**
|
|
* Get or create innovation number for a connection
|
|
*/
|
|
getInnovation(from: number, to: number): number {
|
|
const key = `${from}->${to}`;
|
|
|
|
if (this.innovationHistory.has(key)) {
|
|
return this.innovationHistory.get(key)!;
|
|
}
|
|
|
|
const innovation = this.currentInnovation++;
|
|
this.innovationHistory.set(key, innovation);
|
|
return innovation;
|
|
}
|
|
|
|
/**
|
|
* Reset innovation tracking (useful for new experiments)
|
|
*/
|
|
reset(): void {
|
|
this.currentInnovation = 0;
|
|
this.innovationHistory.clear();
|
|
}
|
|
|
|
/**
|
|
* Get current innovation count
|
|
*/
|
|
getCurrentInnovation(): number {
|
|
return this.currentInnovation;
|
|
}
|
|
}
|
|
|
|
let nextGenomeId = 0;
|
|
|
|
/**
|
|
* Create a minimal genome with only input and output nodes, fully connected
|
|
*/
|
|
export function createMinimalGenome(
|
|
inputCount: number,
|
|
outputCount: number,
|
|
innovationTracker: InnovationTracker
|
|
): Genome {
|
|
const nodes: NodeGene[] = [];
|
|
const connections: ConnectionGene[] = [];
|
|
|
|
// Create input nodes (IDs 0 to inputCount-1)
|
|
// PLUS one extra for Bias
|
|
for (let i = 0; i < inputCount + 1; i++) {
|
|
nodes.push({
|
|
id: i,
|
|
type: 'input',
|
|
activation: 'linear',
|
|
});
|
|
}
|
|
|
|
// Create output nodes (IDs starting from inputCount + 1)
|
|
// Fix: Bias node uses ID `inputCount`, so outputs must start at `inputCount + 1`
|
|
for (let i = 0; i < outputCount; i++) {
|
|
nodes.push({
|
|
id: inputCount + 1 + i,
|
|
type: 'output',
|
|
activation: 'tanh',
|
|
});
|
|
}
|
|
|
|
// Create fully connected minimal genome
|
|
// Iterate through all inputs INCLUDING Bias
|
|
for (let i = 0; i < inputCount + 1; i++) {
|
|
const inputNode = i;
|
|
|
|
for (let o = 0; o < outputCount; o++) {
|
|
const outputNode = inputCount + 1 + o; // target the shifted output IDs
|
|
const innovation = innovationTracker.getInnovation(inputNode, outputNode);
|
|
|
|
let weight = (Math.random() * 2.0) - 1.0;
|
|
|
|
// FORCE AGGRESSION:
|
|
// If connection is from BIAS node (index == inputCount) TO SHOOT node (index 3 of output)
|
|
// Warning: Output indices are 0..4 relative to output block.
|
|
// Shoot is 4th output (moveX, moveY, turn, shoot, reserved).
|
|
if (inputNode === inputCount && o === 3) {
|
|
weight = 1.0 + Math.random(); // Range [1.0, 2.0] -> Strong Positive Bias
|
|
}
|
|
|
|
connections.push({
|
|
innovation,
|
|
from: inputNode,
|
|
to: outputNode,
|
|
weight,
|
|
enabled: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: nextGenomeId++,
|
|
nodes,
|
|
connections,
|
|
fitness: 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Clone a genome (deep copy)
|
|
*/
|
|
export function cloneGenome(genome: Genome): Genome {
|
|
return {
|
|
id: nextGenomeId++,
|
|
nodes: genome.nodes.map(n => ({ ...n })),
|
|
connections: genome.connections.map(c => ({ ...c })),
|
|
fitness: genome.fitness,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get next available node ID
|
|
*/
|
|
export function getNextNodeId(genome: Genome): number {
|
|
return Math.max(...genome.nodes.map(n => n.id)) + 1;
|
|
}
|
|
|
|
/**
|
|
* Check if a connection already exists
|
|
*/
|
|
export function connectionExists(genome: Genome, from: number, to: number): boolean {
|
|
return genome.connections.some(c => c.from === from && c.to === to);
|
|
}
|
|
|
|
/**
|
|
* Check if adding a connection would create a cycle (for feedforward networks)
|
|
*/
|
|
export function wouldCreateCycle(genome: Genome, from: number, to: number): boolean {
|
|
// Build adjacency list
|
|
const adj = new Map<number, number[]>();
|
|
for (const node of genome.nodes) {
|
|
adj.set(node.id, []);
|
|
}
|
|
|
|
for (const conn of genome.connections) {
|
|
if (!conn.enabled) continue;
|
|
if (!adj.has(conn.from)) adj.set(conn.from, []);
|
|
adj.get(conn.from)!.push(conn.to);
|
|
}
|
|
|
|
// Add the proposed connection
|
|
if (!adj.has(from)) adj.set(from, []);
|
|
adj.get(from)!.push(to);
|
|
|
|
// DFS to detect cycle
|
|
const visited = new Set<number>();
|
|
const recStack = new Set<number>();
|
|
|
|
const hasCycle = (nodeId: number): boolean => {
|
|
visited.add(nodeId);
|
|
recStack.add(nodeId);
|
|
|
|
const neighbors = adj.get(nodeId) || [];
|
|
for (const neighbor of neighbors) {
|
|
if (!visited.has(neighbor)) {
|
|
if (hasCycle(neighbor)) return true;
|
|
} else if (recStack.has(neighbor)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
recStack.delete(nodeId);
|
|
return false;
|
|
};
|
|
|
|
// Check from the 'from' node
|
|
return hasCycle(from);
|
|
}
|
|
|
|
/**
|
|
* Serialize genome to JSON
|
|
*/
|
|
export function serializeGenome(genome: Genome): string {
|
|
return JSON.stringify(genome, null, 2);
|
|
}
|
|
|
|
/**
|
|
* Deserialize genome from JSON
|
|
*/
|
|
export function deserializeGenome(json: string): Genome {
|
|
return JSON.parse(json);
|
|
}
|