ai/neural/controllers/perceptron-controller.js

/**
 * @fileoverview Perceptron-Controller  –  Steuerlogik für das Perceptron-Playground.
 *
 * Aufgaben:
 * 1. Dataset auswählen & Datentabelle rendern (mit Vorhersagespalte)
 * 2. Ein einzelnes Neuron (2 Inputs → 1 Output, Step-Aktivierung) steuern
 * 3. Phasenbasiertes Delta-Regel-Training (Schritt / Auto / Speed-Slider)
 * 4. Decision Boundary, Gleichung, Fehler-Highlights live updaten
 * 5. Lernregel-Visualisierung mit Gewichts-Transition
 * 6. Fehler-pro-Epoche Chart
 * 7. Gaussian-Generator Integration
 *
 * Training läuft direkt im Main-Thread (nur 1 Neuron, ~20 Datenpunkte →
 * kein Web-Worker nötig).
 *
 * @module perceptron-controller
 * @author Alexander Wolf
 */

'use strict';

/* global PerceptronVisualizer, PERCEPTRON_DATASETS, GaussianGenerator,
          Neuron, ACTIVATION_FUNCTIONS, NN_CONSTANTS, DebugConfig, DEBUG_DOMAINS */

/**
 * Training Phases for step-through visualization.
 * @enum {number}
 */
const TRAIN_PHASE = {
    IDLE:           0,
    SELECT_POINT:   1,  // Randomly select misclassified point
    UPDATE_W1:      2,  // Show Δw₁ calculation
    UPDATE_W2:      3,  // Show Δw₂ calculation
    APPLY_WEIGHTS:  4,  // Apply new weights, show old→new
    RETRAIN_VIZ:    5,  // Update decision line, table predictions, error chart
};

/**
 * @class PerceptronController
 * @description Orchestriert Perceptron-Training, Visualisierung und UI.
 */
class PerceptronController {

    /**
     * @param {Object} ids  – DOM-Element-IDs
     * @param {string} ids.plotContainer
     * @param {string} ids.archContainer
     * @param {string} ids.equationContainer
     * @param {string} ids.errorChartContainer
     * @param {string} ids.dataTableBody
     * @param {string} ids.dataTableWrapper
     * @param {string} ids.tableTitle
     * @param {string} ids.learningRuleDisplay
     * @param {string} ids.datasetSelector
     * @param {string} ids.learnRateInput
     * @param {string} ids.maxEpochsInput
     * @param {string} ids.speedSlider
     * @param {string} ids.speedLabel
     * @param {string} ids.trainBtn
     * @param {string} ids.stepBtn
     * @param {string} ids.resetBtn
     * @param {string} ids.epochDisplay
     * @param {string} ids.errorDisplay
     * @param {string} ids.accuracyDisplay
     * @param {string} ids.gaussianConfig
     * @param {string} ids.commentsDisplay
     * @param {string} ids.errorChartToggle
     */
    constructor(ids) {
        /** @private */
        this._ids = ids;

        const C = typeof NN_CONSTANTS !== 'undefined' ? NN_CONSTANTS : {};

        // --- Hyperparameter ---
        /** @private */
        this._learningRate = C.PERCEPTRON_DEFAULT_LEARN_RATE || 0.1;
        /** @private */
        this._maxEpochs = C.PERCEPTRON_MAX_EPOCHS || 500;
        /** @private */
        this._trainDelayMin = C.PERCEPTRON_TRAIN_DELAY_MIN_MS || 10;
        /** @private */
        this._trainDelayMax = C.PERCEPTRON_TRAIN_DELAY_MAX_MS || 1200;
        /** @private */
        this._trainDelay = C.PERCEPTRON_TRAIN_DELAY_MS || 200;
        /** @private - Accuracy threshold (0-100%), 100 means full convergence */
        this._accuracyThreshold = 100;

        // --- State ---
        /** @private @type {string} */
        this._currentDatasetKey = 'schwimmen';
        /** @private @type {PerceptronDataset|null} */
        this._dataset = null;
        /** @private @type {Neuron|null} */
        this._neuron = null;
        /** @private */
        this._epoch = 0;
        /** @private */
        this._totalError = 0;
        /** @private */
        this._isTraining = false;
        /** @private */
        this._trainTimerId = null;

        // Phase-based step state
        /** @private */
        this._currentPhase = TRAIN_PHASE.IDLE;
        /** @private */
        this._selectedPointIdx = -1;
        /** @private @type {{ w1: number, w2: number, bias: number }|null} */
        this._prevWeights = null;
        /** @private */
        this._currentError = 0;

        // --- Visualizer ---
        /** @private @type {PerceptronVisualizer|null} */
        this._viz = null;

        // --- DOM Refs (cached) ---
        /** @private */
        this._dom = {};

        this._init();
    }

    // ========================================================================
    // INIT
    // ========================================================================

    /** @private */
    _init() {
        this._cacheDom();
        this._bindEvents();

        this._viz = new PerceptronVisualizer({
            plotContainerId:      this._ids.plotContainer,
            archContainerId:     this._ids.archContainer,
            equationContainerId:  this._ids.equationContainer,
            errorChartContainerId: this._ids.errorChartContainer,
        });

        // Listen for manual input from architecture diagram
        const archEl = document.getElementById(this._ids.archContainer);
        if (archEl) {
            archEl.addEventListener('arch-manual-input', (e) => {
                this._handleManualInput(e.detail.x1, e.detail.x2);
            });
        }

        this._loadDataset(this._currentDatasetKey);
        this._resetNeuron();

        this._debugLog('debug', 'Controller initialisiert', {
            dataset: this._currentDatasetKey,
            learningRate: this._learningRate,
        });
    }

    /** @private */
    _cacheDom() {
        const ids = this._ids;
        this._dom = {
            tableBody:       document.getElementById(ids.dataTableBody),
            tableWrapper:    document.getElementById(ids.dataTableWrapper),
            tableTitle:      document.getElementById(ids.tableTitle),
            learnRuleDisp:   document.getElementById(ids.learningRuleDisplay),
            datasetSelect:   document.getElementById(ids.datasetSelector),
            learnRate:       document.getElementById(ids.learnRateInput),
            maxEpochs:       document.getElementById(ids.maxEpochsInput),
            speedSlider:     document.getElementById(ids.speedSlider),
            speedLabel:      document.getElementById(ids.speedLabel),
            trainBtn:        document.getElementById(ids.trainBtn),
            stepBtn:         document.getElementById(ids.stepBtn),
            resetBtn:        document.getElementById(ids.resetBtn),
            epochDisplay:    document.getElementById(ids.epochDisplay),
            errorDisplay:    document.getElementById(ids.errorDisplay),
            accuracyDisplay: document.getElementById(ids.accuracyDisplay),
            gaussianConfig:  document.getElementById(ids.gaussianConfig),
            errorChartContainer: document.getElementById(ids.errorChartContainer),
            plotContainer:   document.getElementById(ids.plotContainer),
            commentsDisplay: document.getElementById(ids.commentsDisplay),
            accuracyThreshold: document.getElementById(ids.accuracyThreshold),
        };

        // Tab system
        this._tabButtons = document.querySelectorAll('.tab-btn');
        this._tabContents = document.querySelectorAll('.tab-content');
        /** @private */ this._sortCol = null;
        /** @private */ this._sortAsc = true;
    }

    /** @private */
    _bindEvents() {
        const d = this._dom;

        if (d.datasetSelect) {
            d.datasetSelect.addEventListener('change', () => {
                this._currentDatasetKey = d.datasetSelect.value;
                this._loadDataset(this._currentDatasetKey);
                this._resetNeuron();
            });
        }

        if (d.trainBtn)  d.trainBtn.addEventListener('click',  () => this.toggleTrain());
        if (d.stepBtn)   d.stepBtn.addEventListener('click',   () => this.manualStep());
        if (d.resetBtn)  d.resetBtn.addEventListener('click',  () => this._fullReset());

        // Lernrate live
        if (d.learnRate) {
            d.learnRate.addEventListener('change', () => {
                this._learningRate = parseFloat(d.learnRate.value) || 0.1;
            });
        }
        if (d.maxEpochs) {
            d.maxEpochs.addEventListener('change', () => {
                this._maxEpochs = parseInt(d.maxEpochs.value, 10) || 500;
            });
        }

        // Accuracy threshold
        if (d.accuracyThreshold) {
            d.accuracyThreshold.addEventListener('change', () => {
                this._accuracyThreshold = parseFloat(d.accuracyThreshold.value) || 100;
            });
        }

        // Speed slider
        if (d.speedSlider) {
            d.speedSlider.addEventListener('input', () => {
                const val = parseInt(d.speedSlider.value, 10);
                // Invert: slider left = slow (high delay), right = fast (low delay)
                this._trainDelay = this._trainDelayMax - val + this._trainDelayMin;
                this._updateSpeedLabel();
            });
        }

        // Gaussian generator
        this._initGaussianControls();

        // Tab switching
        this._initTabs();

        // Table header sort
        this._initTableSort();
    }

    // ========================================================================
    // DATASET
    // ========================================================================

    /**
     * @private
     * @param {string} key
     */
    _loadDataset(key) {
        this._dataset = PERCEPTRON_DATASETS[key];
        if (!this._dataset) return;

        const isGenerator = this._dataset.isGenerated === true;
        this._renderTable();
        this._viz.setDataset(this._dataset);
        this._updateTableTitle();
        this._toggleGaussianConfig(isGenerator);

        this._debugLog('debug', 'Dataset loaded', {
            name: this._dataset.name,
            points: this._dataset.points.length,
        });
    }

    /**
     * Updates the table panel header with dataset name/description.
     * @private
     */
    _updateTableTitle() {
        if (!this._dom.tableTitle || !this._dataset) return;
        const ds = this._dataset;
        this._dom.tableTitle.textContent =
            `📊 ${ds.icon || ''} ${ds.name}`;
    }

    /** @private */
    _renderTable() {
        const tbody = this._dom.tableBody;
        if (!tbody || !this._dataset) return;

        const ds = this._dataset;
        tbody.innerHTML = '';

        // Update header if feature names changed
        const thead = tbody.closest('table')?.querySelector('thead');
        if (thead) {
            const fn = ds.featureNames || ds.axisLabels;
            thead.innerHTML = `<tr>
                <th data-sort="idx">#</th>
                <th data-sort="x">${fn.x}</th>
                <th data-sort="y">${fn.y}</th>
                <th data-sort="label">Label</th>
                <th data-sort="pred">ŷ</th>
            </tr>`;
            this._initTableSort();
        }

        // Get sorted indices
        const indices = ds.points.map((_, i) => i);
        if (this._sortCol) {
            indices.sort((a, b) => {
                const ptA = ds.points[a];
                const ptB = ds.points[b];
                let va, vb;
                switch (this._sortCol) {
                    case 'idx':   va = a; vb = b; break;
                    case 'x':     va = ptA.x; vb = ptB.x; break;
                    case 'y':     va = ptA.y; vb = ptB.y; break;
                    case 'label': va = ptA.label; vb = ptB.label; break;
                    default:      va = a; vb = b;
                }
                return this._sortAsc ? va - vb : vb - va;
            });
        }

        indices.forEach((i) => {
            const pt = ds.points[i];
            const tr = document.createElement('tr');
            tr.setAttribute('data-idx', i);
            const classLabel = ds.classLabels[pt.label];
            const cssClass = pt.label === 0 ? 'class-0' : 'class-1';

            tr.innerHTML = `
                <td>${i + 1}</td>
                <td>${pt.x.toFixed(1)}</td>
                <td>${pt.y.toFixed(1)}</td>
                <td><span class="label-badge ${cssClass}">${classLabel}</span></td>
                <td class="pred-cell">—</td>
            `;
            tbody.appendChild(tr);
        });
    }

    /**
     * Updates prediction column and row highlights.
     * @private
     */
    _updateTablePredictions() {
        const tbody = this._dom.tableBody;
        if (!tbody || !this._neuron || !this._dataset) return;

        const stepFn = ACTIVATION_FUNCTIONS.step.fn;
        const rows = tbody.querySelectorAll('tr');

        rows.forEach((row) => {
            const idx = parseInt(row.getAttribute('data-idx'), 10);
            if (isNaN(idx)) return;

            const pt = this._dataset.points[idx];
            if (!pt) return;

            const pred = this._neuron.forward(new Float64Array([pt.x, pt.y]), stepFn);
            const predCell = row.querySelector('.pred-cell');
            const isCorrect = pred === pt.label;

            if (predCell) {
                const predLabel = this._dataset.classLabels[pred] || String(pred);
                predCell.innerHTML = `<span class="pred-badge ${isCorrect ? 'correct' : 'wrong'}">${predLabel}</span>`;
            }

            // Row classification highlight
            row.classList.toggle('row-error', !isCorrect);
            row.classList.toggle('row-correct', isCorrect && this._epoch > 0);
            // Selected row
            row.classList.toggle('row-selected', idx === this._selectedPointIdx);

            // Auto-scroll to selected row
            if (idx === this._selectedPointIdx && this._selectedPointIdx >= 0) {
                row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
            }
        });
    }

    // ========================================================================
    // NEURON
    // ========================================================================

    /** @private */
    _resetNeuron() {
        this._stopTraining();
        this._neuron = new Neuron(2, 0, { initMethod: 'random', initScale: NN_CONSTANTS.PERCEPTRON_INIT_SCALE });
        this._epoch = 0;
        this._currentPhase = TRAIN_PHASE.IDLE;
        this._selectedPointIdx = -1;
        this._prevWeights = null;

        // Compute initial error count (random weights usually misclassify some)
        this._computeErrorCount();

        this._updateViz();
        this._updateStats();
        this._updateTablePredictions();
        this._viz.clearErrors();
        this._viz.clearErrorHistory();
        this._viz.highlightSelectedPoint(-1);
        this._clearLearningRuleDisplay();
        this._updateComments();
    }

    /**
     * Counts current misclassified points and updates _totalError.
     * @private
     * @returns {number} Number of errors
     */
    _computeErrorCount() {
        if (!this._neuron || !this._dataset) return 0;
        const stepFn = ACTIVATION_FUNCTIONS.step.fn;
        const points = this._dataset.points;
        let errors = 0;
        for (let i = 0; i < points.length; i++) {
            const pred = this._neuron.forward(new Float64Array([points[i].x, points[i].y]), stepFn);
            if (pred !== points[i].label) errors++;
        }
        this._totalError = errors;
        return errors;
    }

    /**
     * Returns current accuracy in percent.
     * @returns {number}
     * @private
     */
    _currentAccuracy() {
        if (!this._dataset || this._dataset.points.length === 0) return 100;
        const total = this._dataset.points.length;
        return (total - this._totalError) / total * 100;
    }

    // ========================================================================
    // PHASE-BASED STEP TRAINING
    // ========================================================================

    /**
     * Executes one manual step-through phase.
     * Called by the Step button or by auto-training.
     *
     * @param {boolean} [fastMode=false] - Skip visualization phases
     */
    manualStep(fastMode = false) {
        if (!this._neuron || !this._dataset) return;

        if (fastMode) {
            this._executeFullStep();
            return;
        }

        // Advance through phases
        switch (this._currentPhase) {
            case TRAIN_PHASE.IDLE:
            case TRAIN_PHASE.RETRAIN_VIZ:
                this._phaseSelectPoint();
                break;
            case TRAIN_PHASE.SELECT_POINT:
                this._phaseShowW1Update();
                break;
            case TRAIN_PHASE.UPDATE_W1:
                this._phaseShowW2Update();
                break;
            case TRAIN_PHASE.UPDATE_W2:
                this._phaseApplyWeights();
                break;
            case TRAIN_PHASE.APPLY_WEIGHTS:
                this._phaseRetrainViz();
                break;
            default:
                this._phaseSelectPoint();
        }
    }

    /**
     * Phase 1: Select a random misclassified point.
     * @private
     */
    _phaseSelectPoint() {
        this._currentPhase = TRAIN_PHASE.SELECT_POINT;
        const stepFn = ACTIVATION_FUNCTIONS.step.fn;
        const points = this._dataset.points;

        // Find all misclassified
        const misclassified = [];
        for (let i = 0; i < points.length; i++) {
            const pred = this._neuron.forward(new Float64Array([points[i].x, points[i].y]), stepFn);
            if (pred !== points[i].label) {
                misclassified.push(i);
            }
        }

        if (misclassified.length === 0) {
            // Converged!
            this._epoch++;
            this._totalError = 0;
            this._updateStats();
            this._viz.pushErrorHistory(0);
            this._showLearningRuleText(
                `<div class="rule-phase-title">✅ Konvergiert!</div>` +
                `<div class="rule-explanation">Alle ${points.length} Punkte werden korrekt klassifiziert.</div>`
            );
            this._currentPhase = TRAIN_PHASE.IDLE;
            return;
        }

        // Check accuracy threshold (< 100%)
        if (this._accuracyThreshold < 100) {
            const accuracy = (points.length - misclassified.length) / points.length * 100;
            if (accuracy >= this._accuracyThreshold) {
                this._epoch++;
                this._totalError = misclassified.length;
                this._updateStats();
                this._viz.pushErrorHistory(misclassified.length);
                this._showLearningRuleText(
                    `<div class="rule-phase-title">✅ Zielgenauigkeit erreicht!</div>` +
                    `<div class="rule-explanation">Genauigkeit ${accuracy.toFixed(1)}% ≥ Ziel ${this._accuracyThreshold}% ` +
                    `(${misclassified.length} von ${points.length} falsch).</div>`
                );
                this._currentPhase = TRAIN_PHASE.IDLE;
                return;
            }
        }

        // Random selection
        this._selectedPointIdx = misclassified[Math.floor(Math.random() * misclassified.length)];
        const pt = points[this._selectedPointIdx];
        const pred = this._neuron.forward(new Float64Array([pt.x, pt.y]), stepFn);
        this._currentError = pt.label - pred;

        // Save old weights
        this._prevWeights = {
            w1: this._neuron.weights[0],
            w2: this._neuron.weights[1],
            bias: this._neuron.bias,
        };

        // Visual updates
        this._viz.highlightSelectedPoint(this._selectedPointIdx);
        this._viz.updateArchInputValues(pt.x, pt.y, null);
        this._updateTablePredictions();

        const fl = this._featureLabel;
        this._showLearningRuleText(
            `<div class="rule-phase-title">Phase 1 — Punkt auswählen</div>` +
            `<div class="rule-explanation">Ein <strong>falsch klassifizierter</strong> Punkt wird zufällig gewählt.</div>` +
            `<table class="rule-detail-table">` +
            `<tr><td class="rule-label">Punkt</td><td>#${this._selectedPointIdx + 1} (${pt.x.toFixed(1)}, ${pt.y.toFixed(1)})</td></tr>` +
            `<tr><td class="rule-label">Soll (y)</td><td>${pt.label}</td></tr>` +
            `<tr><td class="rule-label">Ist (ŷ)</td><td>${pred}</td></tr>` +
            `<tr><td class="rule-label">Fehler δ</td><td>${pt.label} − ${pred} = <span class="rule-part part-delta">${this._currentError}</span></td></tr>` +
            `</table>` +
            `<div class="rule-hint">δ &gt; 0 → Gewichte müssen <em>erhöht</em> werden<br>δ &lt; 0 → Gewichte müssen <em>verringert</em> werden</div>`
        );

        this._updatePhaseIndicator(1);
    }

    /**
     * Phase 2: Show w₁ update formula.
     * @private
     */
    _phaseShowW1Update() {
        this._currentPhase = TRAIN_PHASE.UPDATE_W1;
        const pt = this._dataset.points[this._selectedPointIdx];
        const lr = this._learningRate;
        const delta = this._currentError;
        const oldW1 = this._prevWeights.w1;
        const dw1 = lr * delta * pt.x;
        const newW1 = oldW1 + dw1;

        // Compute sum for architecture display
        const sum = oldW1 * pt.x + this._prevWeights.w2 * pt.y + this._prevWeights.bias;
        this._viz.updateArchInputValues(pt.x, pt.y, sum);

        const fLabel = this._featureLabel('x');
        this._showLearningRuleText(
            `<div class="rule-phase-title">Phase 2 — Gewicht w₁ berechnen</div>` +
            `<div class="rule-explanation">Die Delta-Regel: <code>w₁<sup>neu</sup> = w₁<sup>alt</sup> + η · δ · x₁</code></div>` +
            `<table class="rule-detail-table">` +
            `<tr><td class="rule-label">Lernrate η</td><td><span class="rule-part part-eta">${lr}</span></td></tr>` +
            `<tr><td class="rule-label">Fehler δ</td><td><span class="rule-part part-delta">${delta}</span></td></tr>` +
            `<tr><td class="rule-label">${fLabel}</td><td><span class="rule-part part-input">${pt.x.toFixed(1)}</span></td></tr>` +
            `<tr><td class="rule-label">Δw₁</td><td>${lr} · ${delta} · ${pt.x.toFixed(1)} = <strong>${dw1.toFixed(4)}</strong></td></tr>` +
            `</table>` +
            `<div class="rule-weight-transition">` +
            `w₁: <span class="weight-old">${oldW1.toFixed(3)}</span> → <span class="weight-new">${newW1.toFixed(3)}</span>` +
            `</div>`
        );

        this._updatePhaseIndicator(2);
    }

    /**
     * Phase 3: Show w₂ and bias update formula.
     * @private
     */
    _phaseShowW2Update() {
        this._currentPhase = TRAIN_PHASE.UPDATE_W2;
        const pt = this._dataset.points[this._selectedPointIdx];
        const lr = this._learningRate;
        const delta = this._currentError;
        const oldW2 = this._prevWeights.w2;
        const dw2 = lr * delta * pt.y;
        const newW2 = oldW2 + dw2;
        const oldBias = this._prevWeights.bias;
        const db = lr * delta;
        const newBias = oldBias + db;

        const fLabel = this._featureLabel('y');
        this._showLearningRuleText(
            `<div class="rule-phase-title">Phase 3 — Gewicht w₂ &amp; Bias</div>` +
            `<div class="rule-explanation">Gleiche Regel für w₂ und den Bias (x₀ = 1).</div>` +
            `<table class="rule-detail-table">` +
            `<tr><td class="rule-label">Δw₂</td><td>${lr} · ${delta} · ${pt.y.toFixed(1)} = <strong>${dw2.toFixed(4)}</strong></td></tr>` +
            `<tr><td class="rule-label">Δb</td><td>${lr} · ${delta} · 1 = <strong>${db.toFixed(4)}</strong></td></tr>` +
            `</table>` +
            `<div class="rule-weight-transition">` +
            `w₂: <span class="weight-old">${oldW2.toFixed(3)}</span> → <span class="weight-new">${newW2.toFixed(3)}</span><br>` +
            `b: <span class="weight-old">${oldBias.toFixed(3)}</span> → <span class="weight-new">${newBias.toFixed(3)}</span>` +
            `</div>`
        );

        this._updatePhaseIndicator(3);
    }

    /**
     * Phase 4: Apply the weight updates.
     * @private
     */
    _phaseApplyWeights() {
        this._currentPhase = TRAIN_PHASE.APPLY_WEIGHTS;
        const pt = this._dataset.points[this._selectedPointIdx];
        const lr = this._learningRate;
        const delta = this._currentError;

        // Apply
        this._neuron.weights[0] += lr * delta * pt.x;
        this._neuron.weights[1] += lr * delta * pt.y;
        this._neuron.bias       += lr * delta;

        // Show old → new in arch diagram
        this._viz.updateArchWeights(
            this._neuron.weights[0],
            this._neuron.weights[1],
            this._neuron.bias,
            { oldW1: this._prevWeights.w1, oldW2: this._prevWeights.w2, oldBias: this._prevWeights.bias }
        );

        this._showLearningRuleText(
            `<div class="rule-phase-title">Phase 4 — Gewichte anwenden</div>` +
            `<div class="rule-explanation">Die berechneten Änderungen werden auf das Neuron übertragen.</div>` +
            `<table class="rule-detail-table">` +
            `<tr><td class="rule-label">w₁</td><td><span class="weight-old">${this._prevWeights.w1.toFixed(3)}</span> → <span class="weight-new">${this._neuron.weights[0].toFixed(3)}</span></td></tr>` +
            `<tr><td class="rule-label">w₂</td><td><span class="weight-old">${this._prevWeights.w2.toFixed(3)}</span> → <span class="weight-new">${this._neuron.weights[1].toFixed(3)}</span></td></tr>` +
            `<tr><td class="rule-label">b</td><td><span class="weight-old">${this._prevWeights.bias.toFixed(3)}</span> → <span class="weight-new">${this._neuron.bias.toFixed(3)}</span></td></tr>` +
            `</table>` +
            `<div class="rule-hint">Beobachte, wie sich die Decision Boundary im Diagramm verschiebt!</div>`
        );

        this._updatePhaseIndicator(4);
    }

    /**
     * Phase 5: Update vis after weight change.
     * @private
     */
    _phaseRetrainViz() {
        this._currentPhase = TRAIN_PHASE.RETRAIN_VIZ;
        this._epoch++;

        this._updateViz();
        this._updateTablePredictions();

        // Count errors for chart
        const stepFn = ACTIVATION_FUNCTIONS.step.fn;
        const points = this._dataset.points;
        const errorIndices = [];
        for (let i = 0; i < points.length; i++) {
            const pred = this._neuron.forward(new Float64Array([points[i].x, points[i].y]), stepFn);
            if (pred !== points[i].label) errorIndices.push(i);
        }
        this._totalError = errorIndices.length;
        this._viz.highlightErrors(errorIndices);
        this._viz.pushErrorHistory(errorIndices.length);
        this._viz.highlightSelectedPoint(-1);
        this._selectedPointIdx = -1;

        this._updateStats();

        const total = this._dataset.points.length;
        const correct = total - errorIndices.length;
        const accuracy = (correct / total * 100).toFixed(1);
        this._showLearningRuleText(
            `<div class="rule-phase-title">Phase 5 — Ergebnis prüfen</div>` +
            `<div class="rule-explanation">Das Neuron wird mit allen Datenpunkten erneut getestet.</div>` +
            `<table class="rule-detail-table">` +
            `<tr><td class="rule-label">Epoche</td><td>${this._epoch}</td></tr>` +
            `<tr><td class="rule-label">Fehler</td><td>${errorIndices.length} / ${total}</td></tr>` +
            `<tr><td class="rule-label">Genauigkeit</td><td>${accuracy}%</td></tr>` +
            `</table>` +
            (errorIndices.length > 0
                ? `<div class="rule-hint">Noch Fehler vorhanden → nächste Epoche beginnt mit Phase 1.</div>`
                : `<div class="rule-hint rule-success">Perfekt! Alle Punkte korrekt klassifiziert. ✅</div>`)
        );

        // Reset arch weights to normal display
        this._viz.updateArchWeights(
            this._neuron.weights[0],
            this._neuron.weights[1],
            this._neuron.bias
        );
        this._viz.updateArchInputValues(null, null, null);

        this._updatePhaseIndicator(5);

        this._updateComments();

        this._debugLog('debug', 'Step complete', {
            epoch: this._epoch,
            errors: errorIndices.length,
            w1: this._neuron.weights[0],
            w2: this._neuron.weights[1],
            bias: this._neuron.bias,
        });
    }

    /**
     * Executes a full training step without phase visualization.
     * Used for fast auto-training.
     * @private
     */
    _executeFullStep() {
        if (!this._neuron || !this._dataset) return;

        const stepFn = ACTIVATION_FUNCTIONS.step.fn;
        const points = this._dataset.points;
        const lr = this._learningRate;

        // Find misclassified
        const misclassified = [];
        for (let i = 0; i < points.length; i++) {
            const pred = this._neuron.forward(new Float64Array([points[i].x, points[i].y]), stepFn);
            if (pred !== points[i].label) misclassified.push(i);
        }

        if (misclassified.length === 0) {
            this._epoch++;
            this._totalError = 0;
            this._updateStats();
            this._updateViz();
            this._viz.pushErrorHistory(0);
            return;
        }

        // Random misclassified point
        const idx = misclassified[Math.floor(Math.random() * misclassified.length)];
        const pt = points[idx];
        const pred = this._neuron.forward(new Float64Array([pt.x, pt.y]), stepFn);
        const error = pt.label - pred;

        // Delta rule update
        this._neuron.weights[0] += lr * error * pt.x;
        this._neuron.weights[1] += lr * error * pt.y;
        this._neuron.bias       += lr * error;

        this._epoch++;

        // Recount errors
        const errorIndices = [];
        for (let i = 0; i < points.length; i++) {
            const p = this._neuron.forward(new Float64Array([points[i].x, points[i].y]), stepFn);
            if (p !== points[i].label) errorIndices.push(i);
        }
        this._totalError = errorIndices.length;

        this._updateViz();
        this._updateStats();
        this._updateTablePredictions();
        this._viz.highlightErrors(errorIndices);
        this._viz.pushErrorHistory(errorIndices.length);
        this._updateComments();
    }

    // ========================================================================
    // AUTO-TRAINING
    // ========================================================================

    /** Startet / stoppt das automatische Training. */
    toggleTrain() {
        if (this._isTraining) {
            this._stopTraining();
        } else {
            this._startTraining();
        }
    }

    /** @private */
    _startTraining() {
        if (this._isTraining) return;
        this._isTraining = true;
        if (this._dom.trainBtn)  this._dom.trainBtn.textContent = '⏸ Stopp';
        if (this._dom.stepBtn)   this._dom.stepBtn.disabled = true;
        if (this._dom.datasetSelect) this._dom.datasetSelect.disabled = true;

        // Compute initial error count so _trainLoop doesn't falsely see 0
        this._computeErrorCount();

        // Already converged before training even started?
        if (this._totalError === 0) {
            this._showLearningRuleText('Alle Punkte bereits korrekt klassifiziert! ✅');
            this._stopTraining();
            return;
        }

        // Already at accuracy threshold?
        if (this._accuracyThreshold < 100 && this._currentAccuracy() >= this._accuracyThreshold) {
            this._showLearningRuleText(
                `Zielgenauigkeit ${this._accuracyThreshold}% bereits erreicht ` +
                `(aktuell: ${this._currentAccuracy().toFixed(1)}%). ✅`
            );
            this._stopTraining();
            return;
        }

        // Reset phase for auto
        this._currentPhase = TRAIN_PHASE.IDLE;
        this._trainLoop();
    }

    /** @private */
    _stopTraining() {
        this._isTraining = false;
        if (this._trainTimerId !== null) {
            clearTimeout(this._trainTimerId);
            this._trainTimerId = null;
        }
        if (this._dom.trainBtn)  this._dom.trainBtn.textContent = '▶ Trainieren';
        if (this._dom.stepBtn)   this._dom.stepBtn.disabled = false;
        if (this._dom.datasetSelect) this._dom.datasetSelect.disabled = false;
    }

    /** @private */
    _trainLoop() {
        if (!this._isTraining) return;

        const isFast = this._trainDelay <= this._trainDelayMin * 3;

        if (isFast) {
            // Fast mode: do multiple full steps per tick
            const batchSize = NN_CONSTANTS.PERCEPTRON_FAST_BATCH_SIZE;
            for (let i = 0; i < batchSize; i++) {
                this._executeFullStep();
                if (this._totalError === 0 || this._epoch >= this._maxEpochs
                    || this._currentAccuracy() >= this._accuracyThreshold) break;
            }
        } else {
            // Visual mode: step through phases
            this.manualStep(false);
        }

        // Stop conditions
        if (this._totalError === 0) {
            this._showLearningRuleText(`Konvergiert nach ${this._epoch} Epochen! ✅`);
            this._showErrorChart();
            this._stopTraining();
            return;
        }
        if (this._accuracyThreshold < 100 && this._currentAccuracy() >= this._accuracyThreshold) {
            this._showLearningRuleText(
                `Zielgenauigkeit ${this._accuracyThreshold}% erreicht nach ${this._epoch} Epochen ` +
                `(aktuell: ${this._currentAccuracy().toFixed(1)}%). ✅`
            );
            this._showErrorChart();
            this._stopTraining();
            return;
        }
        if (this._epoch >= this._maxEpochs) {
            this._showLearningRuleText(`Max. Epochen (${this._maxEpochs}) erreicht. Keine Konvergenz. ⚠️`);
            this._showErrorChart();
            this._stopTraining();
            return;
        }

        const delay = isFast ? this._trainDelayMin : this._trainDelay;
        this._trainTimerId = setTimeout(() => this._trainLoop(), delay);
    }

    // ========================================================================
    // VIZ UPDATE
    // ========================================================================

    /** @private */
    _updateViz() {
        if (!this._neuron) return;
        const w1 = this._neuron.weights[0];
        const w2 = this._neuron.weights[1];
        const b  = this._neuron.bias;

        this._viz.updateDecisionLine(w1, w2, b);
        this._viz.updateArchWeights(w1, w2, b);
    }

    /** @private */
    _updateStats() {
        const d = this._dom;
        if (d.epochDisplay)    d.epochDisplay.textContent = this._epoch;
        if (d.errorDisplay)    d.errorDisplay.textContent = this._totalError;
        if (d.accuracyDisplay && this._dataset) {
            const total = this._dataset.points.length;
            const correct = total - this._totalError;
            d.accuracyDisplay.textContent = `${(correct / total * 100).toFixed(1)}%`;
        }
    }

    // ========================================================================
    // LEARNING RULE DISPLAY
    // ========================================================================

    /**
     * @private
     * @param {string} html
     */
    _showLearningRuleText(html) {
        if (!this._dom.learnRuleDisp) return;
        this._dom.learnRuleDisp.innerHTML = `<div class="rule-formula">${html}</div>`;
    }

    /** @private */
    _clearLearningRuleDisplay() {
        if (!this._dom.learnRuleDisp) return;
        this._dom.learnRuleDisp.innerHTML =
            '<div class="rule-formula">' +
            'w<sub>i,neu</sub> = w<sub>i,alt</sub> + η · (y − ŷ) · x<sub>i</sub>' +
            '</div>';
    }

    /**
     * @private
     * @param {number} phase  1-5
     */
    _updatePhaseIndicator(phase) {
        if (!this._dom.learnRuleDisp) return;

        let existing = this._dom.learnRuleDisp.querySelector('.phase-indicator');
        if (!existing) {
            existing = document.createElement('div');
            existing.className = 'phase-indicator';
            this._dom.learnRuleDisp.appendChild(existing);
        }

        let html = '';
        for (let i = 1; i <= 5; i++) {
            const cls = i < phase ? 'done' : (i === phase ? 'active' : '');
            html += `<div class="phase-dot ${cls}" title="Phase ${i}"></div>`;
        }
        existing.innerHTML = html;
    }

    /**
     * @private
     * @param {'x'|'y'} axis
     * @returns {string}
     */
    _featureLabel(axis) {
        if (!this._dataset) return axis === 'x' ? 'x₁' : 'x₂';
        if (this._dataset.featureNames) return this._dataset.featureNames[axis];
        return this._dataset.axisLabels ? this._dataset.axisLabels[axis] : (axis === 'x' ? 'x₁' : 'x₂');
    }

    // ========================================================================
    // SPEED SLIDER
    // ========================================================================

    /** @private */
    _updateSpeedLabel() {
        if (!this._dom.speedLabel) return;
        if (this._trainDelay <= this._trainDelayMin * 3) {
            this._dom.speedLabel.textContent = 'Turbo';
        } else if (this._trainDelay > this._trainDelayMax * 0.7) {
            this._dom.speedLabel.textContent = 'Langsam';
        } else {
            this._dom.speedLabel.textContent = 'Mittel';
        }
    }

    // ========================================================================
    // GAUSSIAN GENERATOR
    // ========================================================================

    /** @private */
    _initGaussianControls() {
        const panel = this._dom.gaussianConfig;
        if (!panel) return;

        const btn = panel.querySelector('.btn-generate');
        if (btn) {
            btn.addEventListener('click', () => this._generateGaussianDataset());
        }

        // Bind value displays to sliders
        panel.querySelectorAll('input[type="range"]').forEach((slider) => {
            const valEl = slider.parentElement?.querySelector('.slider-value');
            if (valEl) {
                slider.addEventListener('input', () => {
                    valEl.textContent = slider.value;
                });
            }
        });
    }

    /** @private */
    _generateGaussianDataset() {
        if (typeof GaussianGenerator === 'undefined') return;
        const panel = this._dom.gaussianConfig;
        if (!panel) return;

        const val = (id) => parseFloat(panel.querySelector(`#${id}`)?.value || '0');

        const cluster0 = {
            muX: val('g-mu-x0'), muY: val('g-mu-y0'),
            sigmaX: val('g-sigma-x0'), sigmaY: val('g-sigma-y0'),
            n: parseInt(panel.querySelector('#g-n0')?.value || '10', 10),
            label: 0,
        };
        const cluster1 = {
            muX: val('g-mu-x1'), muY: val('g-mu-y1'),
            sigmaX: val('g-sigma-x1'), sigmaY: val('g-sigma-y1'),
            n: parseInt(panel.querySelector('#g-n1')?.value || '10', 10),
            label: 1,
        };

        const points = GaussianGenerator.generateTwoClusters(cluster0, cluster1);

        // Update dataset
        const ds = PERCEPTRON_DATASETS.generisch;
        ds.points = points;

        // Adjust ranges to fit generated data
        const allX = points.map((p) => p.x);
        const allY = points.map((p) => p.y);
        const margin = 1;
        ds.xRange = [Math.floor(Math.min(...allX) - margin), Math.ceil(Math.max(...allX) + margin)];
        ds.yRange = [Math.floor(Math.min(...allY) - margin), Math.ceil(Math.max(...allY) + margin)];

        this._loadDataset('generisch');
        this._resetNeuron();
    }

    /**
     * Toggles between table view and gaussian generator config.
     * @private
     * @param {boolean} show
     */
    _toggleGaussianConfig(show) {
        const panel = this._dom.gaussianConfig;
        if (!panel) return;
        panel.classList.toggle('visible', show);

        // Hide table wrapper when generator is active, show when not
        if (this._dom.tableWrapper) {
            this._dom.tableWrapper.classList.toggle('hidden', show);
        }
    }

    // ========================================================================
    // TAB SYSTEM
    // ========================================================================

    /** @private */
    _initTabs() {
        if (!this._tabButtons || this._tabButtons.length === 0) return;
        this._tabButtons.forEach((btn) => {
            btn.addEventListener('click', () => {
                const tabId = btn.getAttribute('data-tab');
                this._switchTab(tabId);
            });
        });
    }

    /**
     * Switches to the specified tab.
     * @param {string} tabId
     */
    _switchTab(tabId) {
        if (!this._tabButtons) return;
        this._tabButtons.forEach((b) => b.classList.toggle('active', b.getAttribute('data-tab') === tabId));
        this._tabContents.forEach((c) => c.classList.toggle('active', c.id === tabId));
    }

    /**
     * Shows the error chart tab (used automatically after convergence).
     * @private
     */
    _showErrorChart() {
        this._switchTab('tab-error');
    }

    // ========================================================================
    // TABLE SORT
    // ========================================================================

    /** @private */
    _initTableSort() {
        const thead = this._dom.tableBody?.closest('table')?.querySelector('thead');
        if (!thead) return;
        thead.querySelectorAll('th[data-sort]').forEach((th) => {
            th.style.cursor = 'pointer';
            th.addEventListener('click', () => {
                const col = th.getAttribute('data-sort');
                if (this._sortCol === col) {
                    this._sortAsc = !this._sortAsc;
                } else {
                    this._sortCol = col;
                    this._sortAsc = true;
                }
                // Update sort indicators
                thead.querySelectorAll('th[data-sort]').forEach((h) => {
                    h.classList.remove('sort-asc', 'sort-desc');
                });
                th.classList.add(this._sortAsc ? 'sort-asc' : 'sort-desc');
                this._renderTable();
                this._updateTablePredictions();
            });
        });
    }

    // ========================================================================
    // COMMENTS
    // ========================================================================

    /**
     * Updates the comments tab with contextual information.
     * @private
     */
    _updateComments() {
        const el = this._dom.commentsDisplay;
        if (!el) return;

        const comments = [];

        // Dataset info
        if (this._dataset) {
            const ds = this._dataset;
            const c0 = ds.points.filter((p) => p.label === 0).length;
            const c1 = ds.points.filter((p) => p.label === 1).length;
            comments.push({
                title: `Datensatz: ${ds.name}`,
                text: `${ds.points.length} Datenpunkte (${c0}× ${ds.classLabels[0]}, ${c1}× ${ds.classLabels[1]}).`,
                type: 'info'
            });
        }

        // Training status
        if (this._epoch > 0 && this._neuron) {
            const accuracy = this._currentAccuracy().toFixed(1);
            const w1 = this._neuron.weights[0];
            const w2 = this._neuron.weights[1];
            const b = this._neuron.bias;

            comments.push({
                title: `Epoche ${this._epoch}`,
                text: `Genauigkeit: ${accuracy}%, Fehler: ${this._totalError}. ` +
                      `Aktuelle Gewichte: w₁=${w1.toFixed(3)}, w₂=${w2.toFixed(3)}, b=${b.toFixed(3)}.`,
                type: this._totalError === 0 ? 'success' : 'info'
            });

            // Check if decision line is in visible area
            if (Math.abs(w2) > 1e-10) {
                const slope = -(w1 / w2);
                const intercept = -(b / w2);
                const [xMin, xMax] = this._dataset.xRange;
                const [yMin, yMax] = this._dataset.yRange;
                const yAtXmin = slope * xMin + intercept;
                const yAtXmax = slope * xMax + intercept;
                const lineInView = (yAtXmin >= yMin && yAtXmin <= yMax) || (yAtXmax >= yMin && yAtXmax <= yMax) || (yAtXmin < yMin && yAtXmax > yMax) || (yAtXmin > yMax && yAtXmax < yMin);
                if (!lineInView) {
                    comments.push({
                        title: '⚠️ Decision Boundary nicht sichtbar',
                        text: 'Die Entscheidungslinie liegt derzeit außerhalb des sichtbaren Bereichs. Das Neuron hat noch keine guten Gewichte gefunden.',
                        type: 'warning'
                    });
                }
            } else if (Math.abs(w1) > 1e-10) {
                const xConst = -b / w1;
                const [xMin, xMax] = this._dataset.xRange;
                if (xConst < xMin || xConst > xMax) {
                    comments.push({
                        title: '⚠️ Decision Boundary nicht sichtbar',
                        text: 'Die vertikale Entscheidungslinie liegt außerhalb des sichtbaren Bereichs.',
                        type: 'warning'
                    });
                }
            }

            // Convergence comment
            if (this._totalError === 0) {
                comments.push({
                    title: '✅ Konvergiert!',
                    text: `Das Perceptron klassifiziert alle ${this._dataset.points.length} Punkte korrekt nach ${this._epoch} Epochen.`,
                    type: 'success'
                });
            }
        }

        // Render comments
        let html = '';
        if (comments.length === 0) {
            html = '<div class="comment-hint">Hier erscheinen Erklärungen zum aktuellen Trainingsfortschritt.</div>';
        } else {
            html = comments.map((c) =>
                `<div class="comment-item ${c.type === 'warning' ? 'comment-warning' : c.type === 'success' ? 'comment-success' : ''}">` +
                `<div class="comment-title">${c.title}</div>` +
                `<div>${c.text}</div>` +
                `</div>`
            ).join('');
        }
        el.innerHTML = `<div class="comment-section">${html}</div>`;
    }

    // ========================================================================
    // MANUAL INPUT (from architecture diagram)
    // ========================================================================

    /**
     * Handles manual input values entered in the architecture diagram neurons.
     * Computes forward pass and displays result.
     * @private
     * @param {number} x1
     * @param {number} x2
     */
    _handleManualInput(x1, x2) {
        if (!this._neuron) return;
        const stepFn = ACTIVATION_FUNCTIONS.step.fn;
        const sum = this._neuron.weights[0] * x1 + this._neuron.weights[1] * x2 + this._neuron.bias;
        const pred = this._neuron.forward(new Float64Array([x1, x2]), stepFn);

        this._viz.updateArchInputValues(x1, x2, sum);

        this._showLearningRuleText(
            `<div class="rule-phase-title">🔎 Manuelle Eingabe</div>` +
            `<div class="rule-explanation">Vorwärtsdurchlauf mit deinen Werten:</div>` +
            `<table class="rule-detail-table">` +
            `<tr><td class="rule-label">x₁</td><td>${x1.toFixed(1)}</td></tr>` +
            `<tr><td class="rule-label">x₂</td><td>${x2.toFixed(1)}</td></tr>` +
            `<tr><td class="rule-label">Σ</td><td>${this._neuron.weights[0].toFixed(3)}·${x1.toFixed(1)} + ${this._neuron.weights[1].toFixed(3)}·${x2.toFixed(1)} + ${this._neuron.bias.toFixed(3)} = <strong>${sum.toFixed(3)}</strong></td></tr>` +
            `<tr><td class="rule-label">θ(Σ)</td><td>${sum >= 0 ? 'Σ ≥ 0 → 1' : 'Σ < 0 → 0'}</td></tr>` +
            `<tr><td class="rule-label">ŷ</td><td><strong>${pred}</strong> (${this._dataset?.classLabels?.[pred] || pred})</td></tr>` +
            `</table>`
        );
    }

    // ========================================================================
    // FULL RESET
    // ========================================================================

    /** @private */
    _fullReset() {
        this._stopTraining();
        this._loadDataset(this._currentDatasetKey);
        this._resetNeuron();
    }

    // ========================================================================
    // DEBUG LOGGING
    // ========================================================================

    /**
     * @private
     * @param {string} level
     * @param {string} msg
     * @param {Object} [payload]
     */
    _debugLog(level, msg, payload) {
        if (typeof DebugConfig !== 'undefined' && typeof DEBUG_DOMAINS !== 'undefined') {
            DebugConfig.log(DEBUG_DOMAINS.AI_NEURAL_NET, level, msg, payload);
        }
    }
}


// ===== Universeller Export =====
(function (root) {
    root.PerceptronController = PerceptronController;
})(typeof self !== 'undefined' ? self : typeof window !== 'undefined' ? window : this);