ai/neural/core/network.js

/**
 * @fileoverview NeuralNetwork — Zentrales Netzwerk, das Layers verwaltet.
 * 
 * Verantwortlich für:
 * - Aufbau der Netzwerk-Topologie (Neuron → Layer → Network)
 * - Forward-Pass durch alle Schichten
 * - Backward-Pass (Backpropagation) mit Gradient-Descent
 * - Snapshot-Erstellung für Live-Visualisierung über IframeBridge
 * - Manuelle Manipulation von Gewichten/Biases durch den Nutzer
 * 
 * KEINE DOM-Bezüge! Diese Klasse läuft sowohl im Main-Thread als auch im Web Worker.
 * Die Kommunikation mit der View erfolgt ausschließlich über Snapshots, die der
 * NNOrchestrator via IframeBridge weiterleitet.
 *
 * @author Alexander Wolf
 * @see docs/architecture/NEURAL_NET_ARCHITECTURE.md
 */

/* global Layer, ACTIVATION_FUNCTIONS, LOSS_FUNCTIONS, softmax, maskedSoftmax,
          DebugConfig, DEBUG_DOMAINS */

/**
 * @typedef {Object} NetworkTopology
 * @property {number[]} layers - Neuronen pro Layer [inputSize, hidden1, ..., outputSize]
 * @property {string[]} [activations] - Aktivierungsfunktion pro Layer (ohne Input-Layer)
 * @property {string} [outputActivation='softmax'] - Aktivierung des Output-Layers
 * @property {string} [lossFunction='crossEntropy'] - Verlustfunktion
 * @property {number} [learningRate=0.01] - Lernrate
 * @property {string} [initMethod='xavier'] - Gewichts-Initialisierung
 */

/**
 * @typedef {Object} NetworkSnapshot
 * @property {number[]} topology - Layer-Größen [input, h1, ..., output]
 * @property {string[]} activations - Aktivierungsfunktionen pro Layer
 * @property {number} learningRate - Aktuelle Lernrate
 * @property {number} totalParameters - Gesamtzahl der Parameter
 * @property {LayerSnapshot[]} layers - Snapshots aller Layers (ohne Input-Layer)
 * @property {number} epoch - Aktuelle Epoche (falls im Training)
 * @property {number} loss - Aktueller Verlust
 */

/**
 * @typedef {Object} TrainingSample
 * @property {Float64Array|number[]} input - Eingabedaten
 * @property {Float64Array|number[]} target - Zielwerte (One-Hot oder Regression)
 */

/**
 * @typedef {Object} TrainingResult
 * @property {number} epoch - Epoche
 * @property {number} loss - Durchschnittlicher Verlust
 * @property {number} accuracy - Genauigkeit (für Klassifikation)
 */

class NeuralNetwork {
    /**
     * Erstellt ein neues Neuronales Netz mit der gegebenen Topologie.
     *
     * @param {NetworkTopology} topology
     * @example
     * // XOR-Netz: 2 Inputs → 4 Hidden → 1 Output
     * const net = new NeuralNetwork({
     *     layers: [2, 4, 1],
     *     activations: ['relu', 'sigmoid'],
     *     lossFunction: 'mse',
     *     learningRate: 0.1
     * });
     *
     * @example
     * // TTT-Netz: 9 Inputs → 36 Hidden → 36 Hidden → 9 Output (Softmax)
     * const net = new NeuralNetwork({
     *     layers: [9, 36, 36, 9],
     *     activations: ['relu', 'relu', 'softmax'],
     *     lossFunction: 'crossEntropy',
     *     learningRate: 0.01
     * });
     */
    constructor(topology) {
        const {
            layers: layerSizes,
            activations = [],
            lossFunction = 'crossEntropy',
            learningRate = 0.01,
            initMethod = 'xavier'
        } = topology;

        if (layerSizes.length < 2) {
            throw new Error('Netzwerk benötigt mindestens Input und Output Layer');
        }

        /**
         * Topologie als Array der Layer-Größen.
         * @type {number[]}
         */
        this.topologyDef = [...layerSizes];

        /**
         * Lernrate für Gradient Descent.
         * @type {number}
         */
        this.learningRate = learningRate;

        /**
         * Aktuelle Verlustfunktion.
         * @type {LossFunction}
         */
        this.lossFunction = LOSS_FUNCTIONS[lossFunction] || LOSS_FUNCTIONS.crossEntropy;

        /**
         * Name der Verlustfunktion.
         * @type {string}
         */
        this.lossFunctionName = lossFunction;

        /**
         * Alle Layers (ohne Input-Layer — der Input-Layer ist kein echtes Layer-Objekt).
         * Index 0 = erster Hidden-Layer, letzter Index = Output-Layer.
         * @type {Layer[]}
         */
        this.layers = [];

        /**
         * Aktivierungsnamen pro Layer (für Snapshot/Serialisierung).
         * @type {string[]}
         */
        this.activationNames = [];

        /**
         * Aktuelle Epoche (Trainingsfortschritt).
         * @type {number}
         */
        this.epoch = 0;

        /**
         * Letzter berechneter Verlust.
         * @type {number}
         */
        this.lastLoss = 0;

        /**
         * Letzter Input (für Snapshot nach Forward-Pass).
         * @type {Float64Array|null}
         */
        this.lastInput = null;

        // Layers aufbauen
        for (let i = 1; i < layerSizes.length; i++) {
            const isOutputLayer = (i === layerSizes.length - 1);
            const activationName = activations[i - 1] || (isOutputLayer ? 'sigmoid' : 'relu');
            const useSoftmax = activationName === 'softmax';

            this.activationNames.push(activationName);

            this.layers.push(new Layer({
                neuronCount: layerSizes[i],
                inputCount: layerSizes[i - 1],
                activation: useSoftmax ? 'linear' : activationName,
                initMethod: initMethod,
                useSoftmax: useSoftmax
            }, i - 1));
        }

        this._logCreation();
    }

    // ========================================================================
    // FORWARD PASS
    // ========================================================================

    /**
     * Führt einen Forward-Pass durch das gesamte Netzwerk aus.
     * Daten fließen sequenziell durch alle Layers.
     *
     * @param {Float64Array|number[]} input - Eingabedaten (Größe = topologyDef[0])
     * @returns {Float64Array} Output des letzten Layers
     */
    forward(input) {
        this.lastInput = input instanceof Float64Array ? input : new Float64Array(input);

        let currentOutput = this.lastInput;

        for (const layer of this.layers) {
            currentOutput = layer.forward(currentOutput);
        }

        return currentOutput;
    }

    /**
     * Forward-Pass mit Maskierung illegaler Züge (für Game-KI).
     * Wendet maskedSoftmax auf den Output an.
     *
     * @param {Float64Array|number[]} input - Eingabedaten
     * @param {number[]} legalMask - 1 = erlaubter Zug, 0 = blockiert
     * @returns {Float64Array} Maskierte Wahrscheinlichkeitsverteilung
     */
    forwardMasked(input, legalMask) {
        const rawOutput = this.forward(input);
        const outputLayer = this.layers[this.layers.length - 1];

        if (outputLayer.useSoftmax) {
            // Softmax bereits angewendet, aber ohne Maskierung → neu berechnen
            return maskedSoftmax(outputLayer.preActivations, legalMask);
        }

        return rawOutput;
    }

    // ========================================================================
    // BACKWARD PASS (Backpropagation)
    // ========================================================================

    /**
     * Führt einen Backward-Pass (Backpropagation) durch.
     * Berechnet Gradienten und aktualisiert Gewichte mit Gradient Descent.
     *
     * @param {Float64Array|number[]} target - Zielwerte
     * @returns {number} Der berechnete Verlust
     */
    backward(target) {
        const targetArray = target instanceof Float64Array ? target : new Float64Array(target);
        const outputLayer = this.layers[this.layers.length - 1];
        const output = outputLayer.outputs;

        // 1. Loss berechnen
        const loss = this.lossFunction.compute(output, targetArray);
        this.lastLoss = loss;

        // 2. Output-Layer Gradienten (dL/dz für Output-Neuronen)
        const outputGrad = this.lossFunction.derivative(output, targetArray);

        // 3. Deltas berechnen — rückwärts durch alle Layers
        this._computeDeltas(outputGrad);

        // 4. Gewichte aktualisieren
        this._updateWeights();

        return loss;
    }

    /**
     * Berechnet die Delta-Werte (Fehlerterme) für alle Neuronen.
     * 
     * Für den Output-Layer: δ_k = dL/dz_k (direkt aus der Loss-Ableitung)
     * Für Hidden-Layers: δ_j = (Σ δ_k · w_jk) · f'(z_j)
     *
     * @param {Float64Array} outputGradient - Gradient des Output-Layers
     * @private
     */
    _computeDeltas(outputGradient) {
        // Output-Layer: Delta = Loss-Gradient (bei Softmax+CrossEntropy: predicted - target)
        const outputLayer = this.layers[this.layers.length - 1];
        for (let k = 0; k < outputLayer.neuronCount; k++) {
            if (outputLayer.useSoftmax) {
                // Softmax + Cross-Entropy: Vereinfachte Ableitung
                outputLayer.neurons[k].delta = outputGradient[k];
            } else {
                // Andere Aktivierungen: δ = dL/dy · f'(z)
                const neuron = outputLayer.neurons[k];
                const activationDeriv = outputLayer.activation.derivative(
                    neuron.output, neuron.preActivation
                );
                neuron.delta = outputGradient[k] * activationDeriv;
            }
        }

        // Hidden-Layers: Rückwärts propagieren
        for (let l = this.layers.length - 2; l >= 0; l--) {
            const currentLayer = this.layers[l];
            const nextLayer = this.layers[l + 1];

            for (let j = 0; j < currentLayer.neuronCount; j++) {
                // Summe der gewichteten Deltas aus dem nächsten Layer
                let errorSum = 0;
                for (let k = 0; k < nextLayer.neuronCount; k++) {
                    errorSum += nextLayer.neurons[k].delta * nextLayer.neurons[k].weights[j];
                }

                const neuron = currentLayer.neurons[j];
                const activationDeriv = currentLayer.activation.derivative(
                    neuron.output, neuron.preActivation
                );
                neuron.delta = errorSum * activationDeriv;
            }
        }
    }

    /**
     * Aktualisiert alle Gewichte und Biases mit Gradient Descent.
     * w_new = w_old - learningRate · δ · input
     * b_new = b_old - learningRate · δ
     *
     * @private
     */
    _updateWeights() {
        for (let l = 0; l < this.layers.length; l++) {
            const layer = this.layers[l];
            // Input für diesen Layer: Output des vorherigen Layers (oder Netzwerk-Input)
            const prevOutput = l === 0 ? this.lastInput : this.layers[l - 1].outputs;
            if (!prevOutput) continue; // lastInput ist null wenn forward() noch nicht aufgerufen wurde

            for (let j = 0; j < layer.neuronCount; j++) {
                const neuron = layer.neurons[j];

                // Gewichte aktualisieren
                if (!neuron.gradients) {
                    neuron.gradients = new Float64Array(neuron.weights.length);
                }

                for (let i = 0; i < neuron.weights.length; i++) {
                    const gradient = neuron.delta * prevOutput[i];
                    neuron.gradients[i] = gradient; // Für Visualisierung speichern
                    neuron.weights[i] -= this.learningRate * gradient;
                }

                // Bias aktualisieren
                neuron.bias -= this.learningRate * neuron.delta;
            }
        }
    }

    // ========================================================================
    // TRAINING
    // ========================================================================

    /**
     * Trainiert das Netz mit einem einzelnen Datenpunkt.
     *
     * @param {Float64Array|number[]} input
     * @param {Float64Array|number[]} target
     * @returns {number} Verlust für diesen Datenpunkt
     */
    trainSingle(input, target) {
        this.forward(input);
        return this.backward(target);
    }

    /**
     * Trainiert das Netz mit einem Batch von Datenpunkten (eine Epoche).
     * Stochastic Gradient Descent: Ein Update pro Sample.
     *
     * @param {TrainingSample[]} dataset - Array von {input, target} Paaren
     * @returns {TrainingResult} Epochen-Statistik
     */
    trainEpoch(dataset) {
        let totalLoss = 0;
        let correct = 0;

        // Shuffle (Fisher-Yates) für stochastisches Training
        const shuffled = this._shuffleArray(dataset);

        for (const sample of shuffled) {
            this.forward(sample.input);
            const loss = this.backward(sample.target);
            totalLoss += loss;

            // Accuracy: Argmax des Outputs === Argmax des Targets
            const outputLayer = this.layers[this.layers.length - 1];
            const predictedIdx = this._argmax(outputLayer.outputs);
            const targetIdx = this._argmax(sample.target);
            if (predictedIdx === targetIdx) correct++;
        }

        this.epoch++;
        const avgLoss = totalLoss / dataset.length;
        const accuracy = correct / dataset.length;
        this.lastLoss = avgLoss;

        return {
            epoch: this.epoch,
            loss: avgLoss,
            accuracy: accuracy
        };
    }

    // ========================================================================
    // SNAPSHOT & INTERAKTION
    // ========================================================================

    /**
     * Erstellt einen vollständigen Snapshot des Netzwerks.
     * Dieser wird über die IframeBridge an die Visualisierung gesendet.
     * 
     * Alle Daten werden kopiert (Immutable Pattern, Convention §5).
     *
     * @returns {NetworkSnapshot}
     */
    getSnapshot() {
        return {
            topology: [...this.topologyDef],
            activations: [...this.activationNames],
            learningRate: this.learningRate,
            totalParameters: this.getTotalParameterCount(),
            layers: this.layers.map(l => l.getSnapshot()),
            epoch: this.epoch,
            loss: this.lastLoss
        };
    }

    /**
     * Erstellt einen kompakten Snapshot (nur Gewichte und Outputs, ohne Gradienten).
     * Für High-Frequency Updates während des Trainings.
     *
     * @returns {Object} Kompakter Snapshot
     */
    getCompactSnapshot() {
        return {
            epoch: this.epoch,
            loss: this.lastLoss,
            layerOutputs: this.layers.map(l => new Float64Array(l.outputs)),
            weights: this.layers.map(l =>
                l.neurons.map(n => ({
                    w: new Float64Array(n.weights),
                    b: n.bias
                }))
            )
        };
    }

    /**
     * Setzt ein einzelnes Gewicht manuell.
     *
     * @param {number} layerIndex - Index des Layers (0-basiert, ohne Input-Layer)
     * @param {number} neuronIndex - Index des Neurons im Layer
     * @param {number} weightIndex - Index des Gewichts
     * @param {number} value - Neuer Wert
     */
    setWeight(layerIndex, neuronIndex, weightIndex, value) {
        if (layerIndex < 0 || layerIndex >= this.layers.length) {
            this._logError('Invalid layer index', { layerIndex });
            return;
        }
        this.layers[layerIndex].setWeight(neuronIndex, weightIndex, value);
    }

    /**
     * Setzt den Bias eines Neurons manuell.
     *
     * @param {number} layerIndex
     * @param {number} neuronIndex
     * @param {number} value
     */
    setBias(layerIndex, neuronIndex, value) {
        if (layerIndex < 0 || layerIndex >= this.layers.length) {
            this._logError('Invalid layer index', { layerIndex });
            return;
        }
        this.layers[layerIndex].setBias(neuronIndex, value);
    }

    /**
     * Setzt die Lernrate dynamisch (für Nutzer-Interaktion via Bridge).
     *
     * @param {number} rate - Neue Lernrate (z.B. 0.001 bis 1.0)
     */
    setLearningRate(rate) {
        this.learningRate = Math.max(0.0001, Math.min(10, rate));
    }

    /**
     * Gibt die Gesamtzahl aller Parameter (Gewichte + Biases) zurück.
     *
     * @returns {number}
     */
    getTotalParameterCount() {
        return this.layers.reduce((sum, layer) => sum + layer.getParameterCount(), 0);
    }

    /**
     * Serialisiert das Netzwerk in ein JSON-kompatibles Objekt.
     * Kann zum Speichern/Laden des trainierten Netzes verwendet werden.
     *
     * @returns {Object} Serialisiertes Netzwerk
     */
    serialize() {
        return {
            topology: this.topologyDef,
            activations: this.activationNames,
            learningRate: this.learningRate,
            lossFunction: this.lossFunctionName,
            epoch: this.epoch,
            layers: this.layers.map(layer => ({
                neurons: layer.neurons.map(neuron => ({
                    weights: Array.from(neuron.weights),
                    bias: neuron.bias
                }))
            }))
        };
    }

    /**
     * Lädt Gewichte aus einem serialisierten Objekt.
     *
     * @param {Object} data - Serialisiertes Netzwerk (von serialize())
     */
    deserialize(data) {
        const d = /** @type {any} */ (data);
        if (!d || !d.layers) {
            this._logError('Invalid deserialization data', { data });
            return;
        }

        for (let l = 0; l < this.layers.length && l < d.layers.length; l++) {
            const layerData = d.layers[l];
            const layer = this.layers[l];

            for (let n = 0; n < layer.neuronCount && n < layerData.neurons.length; n++) {
                const neuronData = layerData.neurons[n];
                const neuron = layer.neurons[n];

                neuron.weights.set(neuronData.weights);
                neuron.bias = neuronData.bias;
            }
        }

        this.epoch = d.epoch || 0;
        this.learningRate = d.learningRate || this.learningRate;
    }

    // ========================================================================
    // PRIVATE HELPERS
    // ========================================================================

    /**
     * Fisher-Yates Shuffle für stochastisches Training.
     * @param {TrainingSample[]} array
     * @returns {TrainingSample[]}
     * @private
     */
    _shuffleArray(array) {
        const shuffled = [...array];
        for (let i = shuffled.length - 1; i > 0; i--) {
            const j = Math.floor(Math.random() * (i + 1));
            [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
        }
        return shuffled;
    }

    /**
     * Index des größten Werts in einem Array.
     * @param {Float64Array|number[]} arr
     * @returns {number}
     * @private
     */
    _argmax(arr) {
        let maxIdx = 0;
        let maxVal = arr[0];
        for (let i = 1; i < arr.length; i++) {
            if (arr[i] > maxVal) {
                maxVal = arr[i];
                maxIdx = i;
            }
        }
        return maxIdx;
    }

    /**
     * @param {string} message
     * @param {Object} payload
     * @private
     */
    _logError(message, payload) {
        if (typeof DebugConfig !== 'undefined') {
            DebugConfig.log(DEBUG_DOMAINS.AI_NEURAL_NET, 'error', message, payload);
        }
    }

    /**
     * Loggt die Netzwerk-Erstellung.
     * @private
     */
    _logCreation() {
        if (typeof DebugConfig !== 'undefined') {
            DebugConfig.log(DEBUG_DOMAINS.AI_NEURAL_NET, 'debug',
                'NeuralNetwork created:', {
                    topology: this.topologyDef,
                    activations: this.activationNames,
                    totalParams: this.getTotalParameterCount(),
                    learningRate: this.learningRate,
                    lossFunction: this.lossFunctionName
                });
        }
    }
}

// Export für globalen Zugriff (Window UND Worker-Kontext)
(function(root) {
    root.NeuralNetwork = NeuralNetwork;
})(typeof self !== 'undefined' ? self : typeof window !== 'undefined' ? window : this);