diff --git a/AI-Car-Racer/adaptiveGates.js b/AI-Car-Racer/adaptiveGates.js new file mode 100644 index 0000000..1847e82 --- /dev/null +++ b/AI-Car-Racer/adaptiveGates.js @@ -0,0 +1,839 @@ +// adaptiveGates.js — opt-in curriculum for green checkpoint lines. +// +// When enabled: +// 1. Measure reach rates from popCheckpoints each generation. +// 2. Find the bottleneck gate (largest drop in reach rate). +// 3. Use crash positions (popDeathXY — same signal as Heat mode) to aim +// nudges: pull the hard gate toward the approach side of the death +// cluster so cars get credit / next-CP direction *before* the wall. +// 4. Occasionally ADD a gate (midway before a severe cliff) or REMOVE a +// near-redundant intermediate. +// 5. Remember high-survival layouts + crash centroids per track. +// +// Relation to Heat / ruvector: +// Heat mode — live viz of death deposits. +// Worker popDeathXY — same events, authoritative end-of-gen positions. +// Crash-map HNSW (ruvectorBridge) — encodes the death grid (16×9) into a +// 144-d vector, stores it with the gate layout + survival, retrieves +// similar past crash problems so we can reuse layouts that worked. +// Brains archive — still separate (who drives); crash maps are curriculum. +// +// Safety rails (from prior Triangle curriculum failures): +// - Never reorder walls; never change gate *order*, only insert/delete slots +// - Min 4 gates; max baseline.length + MAX_EXTRA +// - Protect first & last gate from removal (spawn / lap seal) +// - Topology changes at most every TOPO_EVERY adapt cycles +// - Hard caps on nudge distance from baseline when lengths match +// +// Off by default. 🧪 Experiments → "Adaptive green gates" or `?adaptGates=1`. + +(function (global) { + 'use strict'; + + const MAX_STEP = 0.08; + const MAX_DRIFT_PX = 55; + const MAX_STEP_PX = 14; + const MIN_GENS_BETWEEN = 1; + const MIN_GATES = 4; + const MAX_EXTRA = 2; // may grow to baseline.length + MAX_EXTRA + const TOPO_EVERY = 3; // topology change cooldown (adapt cycles) + const ADD_DROP = 0.32; // cliff size that can trigger an insert + const REMOVE_RATIO = 0.97; // rates[k]/rates[k-1] above this → redundant + const REMOVE_MIN_REACH = 0.20; // only prune if enough cars reach the pair + const MEMORY_PREFIX = 'vv_adapt_gates_'; + const MEMORY_RING = 6; + + const state = { + enabled: false, + baseline: null, + lastStatus: 'off', + lastBottleneck: -1, + lastSurvival: null, + lastCrashCentroid: null, // {x,y,n} dominant death cluster this gen + lastCrashHit: null, // best HNSW crash-map retrieval this gen + hnswHits: 0, + hnswApplies: 0, + badStreak: 0, + nudgeCount: 0, + addCount: 0, + removeCount: 0, + genSinceAdapt: 0, + topoCooldown: 0, + trackKey: null, + }; + + const CRASH_SIM_MIN = 0.55; // min cosine sim to trust a retrieved layout + const CRASH_SURV_LIFT = 0.03; // retrieved survival must beat current by this + + try { + if (new URLSearchParams(location.search).get('adaptGates') === '1') { + state.enabled = true; + } + } catch (_) {} + + function cloneCps(cps) { + if (!cps || !cps.length) return []; + return cps.map(function (seg) { + return [ + { x: +seg[0].x, y: +seg[0].y }, + { x: +seg[1].x, y: +seg[1].y }, + ]; + }); + } + + function mid(seg) { + return { + x: (seg[0].x + seg[1].x) * 0.5, + y: (seg[0].y + seg[1].y) * 0.5, + }; + } + + function dist(a, b) { + return Math.hypot(a.x - b.x, a.y - b.y); + } + + function nudgeToward(p, target, t) { + return { + x: p.x + (target.x - p.x) * t, + y: p.y + (target.y - p.y) * t, + }; + } + + function clampStep(from, to, maxPx) { + const d = dist(from, to); + if (d <= maxPx || d < 1e-6) return to; + const s = maxPx / d; + return { x: from.x + (to.x - from.x) * s, y: from.y + (to.y - from.y) * s }; + } + + function clampDrift(p, base, maxPx) { + if (!base) return p; + const d = dist(p, base); + if (d <= maxPx || d < 1e-6) return p; + const s = maxPx / d; + return { x: base.x + (p.x - base.x) * s, y: base.y + (p.y - base.y) * s }; + } + + /** Linear blend of two gate segments (for inserting a midway gate). */ + function lerpGate(a, b, t) { + return [ + { + x: a[0].x + (b[0].x - a[0].x) * t, + y: a[0].y + (b[0].y - a[0].y) * t, + }, + { + x: a[1].x + (b[1].x - a[1].x) * t, + y: a[1].y + (b[1].y - a[1].y) * t, + }, + ]; + } + + function currentCps() { + try { + if (typeof road !== 'undefined' && road && road.roadEditor && + road.roadEditor.checkPointListEditor && + road.roadEditor.checkPointListEditor.length) { + return road.roadEditor.checkPointListEditor; + } + } catch (_) {} + return null; + } + + function maxGates() { + const baseN = (state.baseline && state.baseline.length) || 0; + return Math.max(MIN_GATES, baseN + MAX_EXTRA); + } + + /** + * Stable id for the *walls* (not the adaptive gate list). Used so Triangle + * memory never re-applies onto Rectangle after a preset switch. + */ + function geometrySignature() { + try { + const re = road.roadEditor; + if (!re) return 'g0'; + const parts = []; + const pushPts = function (arr) { + if (!arr) return; + for (let i = 0; i < arr.length; i++) { + parts.push(Math.round(arr[i].x) + ',' + Math.round(arr[i].y)); + } + }; + pushPts(re.points); + parts.push('|'); + pushPts(re.points2); + // Include *baseline* gate count when known; avoid live adaptive gate + // count so adds/removes don't thrash the track key every gen. + if (state.baseline && state.baseline.length) { + parts.push('|b' + state.baseline.length); + } + let h = 2166136261; + const s = parts.join(';'); + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return 'g' + (h >>> 0).toString(16); + } catch (_) { + return 'g0'; + } + } + + function trackKey() { + return MEMORY_PREFIX + geometrySignature(); + } + + /** True when adaptive state still refers to a different wall geometry. */ + function trackStale() { + if (!state.trackKey) return true; + // Compare without the baseline-length suffix drift: recompute from walls only. + try { + const re = road.roadEditor; + if (!re || !re.points || !re.points2) return true; + // If baseline exists, check first wall point still matches baseline context + // by seeing whether live walls match what we hashed into trackKey. + return state.trackKey !== trackKey(); + } catch (_) { + return true; + } + } + + function loadMemory(key) { + try { + const raw = localStorage.getItem(key); + if (!raw) return null; + return JSON.parse(raw); + } catch (_) { return null; } + } + + function saveMemory(key, mem) { + try { localStorage.setItem(key, JSON.stringify(mem)); } catch (_) {} + } + + function captureBaseline() { + const cps = currentCps(); + if (!cps || !cps.length) return false; + state.baseline = cloneCps(cps); + // Hash walls *after* baseline length is known so trackKey is stable. + state.trackKey = trackKey(); + state.nudgeCount = 0; + state.addCount = 0; + state.removeCount = 0; + state.badStreak = 0; + state.lastBottleneck = -1; + state.lastSurvival = null; + state.lastCrashCentroid = null; + state.lastCrashHit = null; + state.topoCooldown = 0; + state.genSinceAdapt = 0; + return true; + } + + /** + * Called when the user loads a different preset / redraws the track. + * Drops Triangle baselines so Reset / HNSW / bad-streak restore cannot + * paint the old green gates onto the new walls. + */ + function onTrackChange() { + // Drop stale adaptive state first so trackKey() doesn't include the old + // baseline gate count in the geometry signature. + state.baseline = null; + state.trackKey = null; + state.nudgeCount = 0; + state.addCount = 0; + state.removeCount = 0; + state.badStreak = 0; + state.lastBottleneck = -1; + state.lastSurvival = null; + state.lastCrashCentroid = null; + state.lastCrashHit = null; + state.topoCooldown = 0; + state.genSinceAdapt = 0; + state._hnswNote = null; + + const ok = captureBaseline(); + // Do NOT restoreBestIfAny() here — that re-applies an adapted layout and + // is the wrong move right after an explicit preset load (preset CPs win). + state.lastStatus = state.enabled + ? (ok + ? ('on · track changed · baseline = ' + state.baseline.length + ' gates (preset)') + : 'on · track changed · no gates yet') + : (ok ? 'off · track changed · baseline recaptured' : 'off'); + return ok; + } + + function applyCps(cps) { + if (!cps || !cps.length) return false; + try { + if (typeof road === 'undefined' || !road || !road.roadEditor) return false; + road.roadEditor.checkPointListEditor = cloneCps(cps); + road.checkPointList = cloneCps(cps); + if (typeof road.rebuildGrids === 'function') road.rebuildGrids(); + try { localStorage.setItem('checkPointList', JSON.stringify(cps)); } catch (_) {} + if (typeof invalidateWorkerInit === 'function') invalidateWorkerInit(); + if (window.DemoPresentation && window.DemoPresentation.invalidateRoad) { + window.DemoPresentation.invalidateRoad(); + } + try { + if (typeof embedCurrentTrack === 'function') embedCurrentTrack(); + } catch (_) {} + return true; + } catch (e) { + console.warn('[adaptiveGates] apply failed', e); + return false; + } + } + + function reachRates(popCheckpoints, cpLen, N) { + const rates = new Array(cpLen + 1); + rates[0] = 1; + for (let k = 1; k <= cpLen; k++) { + let c = 0; + for (let i = 0; i < N; i++) if ((popCheckpoints[i] | 0) >= k) c++; + rates[k] = c / N; + } + return rates; + } + + function findBottleneck(rates) { + let bestK = 1; + let bestDrop = -1; + for (let k = 1; k < rates.length; k++) { + const drop = rates[k - 1] - rates[k]; + if (drop > bestDrop) { + bestDrop = drop; + bestK = k; + } + } + return { gate: bestK, drop: bestDrop }; + } + + /** + * Mean death position for cars that died after clearing `afterCp` gates + * but not more (failed trying for the next gate). Falls back to global + * death mean. Coordinates come from the worker (same events Heat paints). + */ + function crashCentroid(genData, afterCp) { + const N = genData.popN | 0; + const xy = genData.popDeathXY; + const counts = genData.popCheckpoints; + if (!xy || !N) return null; + + let sx = 0, sy = 0, n = 0; + let gsx = 0, gsy = 0, gn = 0; + for (let i = 0; i < N; i++) { + const x = xy[i * 2], y = xy[i * 2 + 1]; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + gsx += x; gsy += y; gn++; + if (counts && (counts[i] | 0) === (afterCp | 0)) { + sx += x; sy += y; n++; + } + } + if (n >= 3) return { x: sx / n, y: sy / n, n: n, scoped: true }; + if (gn >= 5) return { x: gsx / gn, y: gsy / gn, n: gn, scoped: false }; + return null; + } + + /** + * Target for nudging gate b: blend previous-gate mid with crash centroid + * so the gate moves into the approach corridor, not onto the wall. + */ + function crashAwareTarget(next, b, crash) { + let prevMid; + if (b <= 0) { + try { + prevMid = (typeof startInfo !== 'undefined' && startInfo) + ? { x: startInfo.x, y: startInfo.y } + : mid(next[0]); + } catch (_) { + prevMid = mid(next[0]); + } + } else { + prevMid = mid(next[b - 1]); + } + if (!crash) return prevMid; + return { + x: prevMid.x * 0.65 + crash.x * 0.35, + y: prevMid.y * 0.65 + crash.y * 0.35, + }; + } + + /** Index of most redundant intermediate gate (0-based), or -1. */ + function findRedundant(rates, cpLen) { + // Prefer removing the *easiest* intermediate transition (highest pass ratio), + // never first/last. + let bestI = -1; + let bestRatio = 0; + for (let k = 2; k <= cpLen - 1; k++) { + // k is 1-based gate; intermediate if not first/last + if (rates[k - 1] < REMOVE_MIN_REACH) continue; + const ratio = rates[k - 1] > 1e-6 ? rates[k] / rates[k - 1] : 0; + if (ratio >= REMOVE_RATIO && ratio > bestRatio) { + bestRatio = ratio; + bestI = k - 1; // 0-based + } + } + return bestI; + } + + function tryInsert(next, b, rates, drop, crash) { + // b = 0-based bottleneck index. Insert a gate *before* it (between b-1 and b) + // so the cliff is split into two smaller steps. If we have a crash centroid, + // bias the new gate toward the approach-to-crash locus. + if (state.topoCooldown > 0) return null; + if (drop < ADD_DROP) return null; + if (next.length >= maxGates()) return null; + if (b < 1) return null; // need a previous gate to interpolate from + + const prev = next[b - 1]; + const cur = next[b]; + // Avoid stacking inserts when gates are already very close. + if (dist(mid(prev), mid(cur)) < 80) return null; + + let inserted = lerpGate(prev, cur, 0.5); + if (crash) { + // Slide the synthetic gate so its midpoint sits nearer the crash approach. + const m = mid(inserted); + const aim = { + x: m.x * 0.45 + crash.x * 0.55, + y: m.y * 0.45 + crash.y * 0.55, + }; + const dx = aim.x - m.x, dy = aim.y - m.y; + inserted = [ + { x: inserted[0].x + dx, y: inserted[0].y + dy }, + { x: inserted[1].x + dx, y: inserted[1].y + dy }, + ]; + } + next.splice(b, 0, inserted); + state.addCount++; + state.topoCooldown = TOPO_EVERY; + return { + kind: 'add', + msg: 'added gate before #' + (b + 1) + + ' (cliff ' + (drop * 100).toFixed(0) + 'pp' + + (crash ? ', crash-aimed' : '') + + ') · now ' + next.length + ' gates', + }; + } + + function tryRemove(next, rates) { + if (state.topoCooldown > 0) return null; + if (next.length <= MIN_GATES) return null; + // Only prune when the population is doing OK overall — don't strip + // structure while everything is still dying early. + if (rates[1] < 0.15) return null; + + const idx = findRedundant(rates, next.length); + if (idx < 0) return null; + + next.splice(idx, 1); + state.removeCount++; + state.topoCooldown = TOPO_EVERY; + return { + kind: 'remove', + msg: 'removed redundant gate #' + (idx + 1) + + ' · now ' + next.length + ' gates', + }; + } + + function tryNudge(next, b, crash) { + const target = crashAwareTarget(next, b, crash); + const usedCrash = !!(crash && crash.n >= 3); + + const canClamp = state.baseline && state.baseline.length === next.length && b < state.baseline.length; + for (let e = 0; e < 2; e++) { + let p = nudgeToward(next[b][e], target, MAX_STEP); + p = clampStep(next[b][e], p, MAX_STEP_PX); + if (canClamp) p = clampDrift(p, state.baseline[b][e], MAX_DRIFT_PX); + next[b][e] = p; + } + state.nudgeCount++; + return { + kind: 'nudge', + msg: 'nudged gate #' + (b + 1) + + (usedCrash + ? ' toward crash heat (n=' + crash.n + (crash.scoped ? ', scoped' : '') + ')' + : (' toward ' + (b <= 0 ? 'spawn' : ('#' + b)))), + }; + } + + function encodeCrashVec(genData) { + try { + if (window.CrashMapCodec && window.CrashMapCodec.encodeDeathMap) { + return window.CrashMapCodec.encodeDeathMap( + genData.popDeathXY, genData.popN | 0, + (typeof canvas !== 'undefined' && canvas && canvas.width) || 3200, + (typeof canvas !== 'undefined' && canvas && canvas.height) || 1800 + ); + } + const b = window.__rvBridge; + if (b && typeof b.encodeCrashMap === 'function') { + return b.encodeCrashMap(genData.popDeathXY, genData.popN | 0); + } + } catch (_) {} + return null; + } + + function causesOf(genData) { + try { + if (window.CrashMapCodec && window.CrashMapCodec.causeHistogram) { + return window.CrashMapCodec.causeHistogram(genData.popDeathCauses, genData.popN | 0); + } + } catch (_) {} + return null; + } + + /** + * Query HNSW for similar crash maps. If a hit carries a gate layout with + * better survival, apply it (full replace). Returns status string or null. + */ + function tryHnswLayout(crashVec, survival, genData) { + const b = window.__rvBridge; + if (!b || typeof b.recommendCrashLayouts !== 'function') return null; + if (window.rvDisabled) return null; + let hits; + try { hits = b.recommendCrashLayouts(crashVec, 5); } + catch (_) { return null; } + if (!hits || !hits.length) return null; + state.hnswHits++; + state.lastCrashHit = hits[0]; + + // Prefer highest survival among sufficiently similar maps on *this* wall geometry. + const geo = geometrySignature(); + let best = null; + for (let i = 0; i < hits.length; i++) { + const h = hits[i]; + if (!h.cps || !h.cps.length) continue; + // Reject layouts archived on a different track (Triangle ≠ Rectangle). + // Require geometrySig: older untagged maps are never auto-applied + // (avoids painting Triangle gates onto Rectangle after a preset switch). + if (!h.geometrySig || h.geometrySig !== geo) continue; + if ((h.similarity || 0) < CRASH_SIM_MIN) continue; + if ((h.survival || 0) < survival + CRASH_SURV_LIFT) continue; + if (!best || h.survival > best.survival || + (h.survival === best.survival && h.similarity > best.similarity)) { + best = h; + } + } + if (!best) { + return 'hnsw: ' + hits.length + ' similar crash map(s), none beat survival'; + } + if (!applyCps(best.cps)) return 'hnsw: apply failed'; + state.hnswApplies++; + state.topoCooldown = TOPO_EVERY; + return 'hnsw: applied layout from similar crash (sim ' + + (best.similarity * 100).toFixed(0) + '%, surv ' + + (best.survival * 100).toFixed(0) + '%, ' + best.cps.length + ' gates)'; + } + + function archiveCrashToBridge(crashVec, survival, fitness, cps, genData, bottleneck) { + const b = window.__rvBridge; + if (!b || typeof b.archiveCrashMap !== 'function') return; + if (window.rvDisabled) return; + let nDeaths = 0; + const xy = genData.popDeathXY; + const N = genData.popN | 0; + if (xy) { + for (let i = 0; i < N; i++) { + if (Number.isFinite(xy[i * 2]) && Number.isFinite(xy[i * 2 + 1])) nDeaths++; + } + } + try { + b.archiveCrashMap(crashVec, { + survival: survival, + fitness: fitness || 0, + generation: (typeof generation === 'number' ? generation : 0), + nDeaths: nDeaths, + nGates: cps.length, + cps: cloneCps(cps), + causes: causesOf(genData), + bottleneck: bottleneck, + geometrySig: geometrySignature(), + }); + } catch (e) { + console.warn('[adaptiveGates] archiveCrashMap failed', e); + } + } + + function adaptOnce(genData) { + const cps = currentCps(); + if (!cps || cps.length < 2) { + state.lastStatus = 'on · need ≥2 gates'; + return false; + } + // Track switch while adaptive is on: recapture baseline before any nudge/HNSW. + if (!state.baseline || trackStale()) { + onTrackChange(); + } + + const N = genData.popN | 0; + const popCp = genData.popCheckpoints; + if (!N || !popCp || popCp.length < N) { + state.lastStatus = 'on · waiting for pop stats'; + return false; + } + + const cpLen = cps.length; + const rates = reachRates(popCp, cpLen, N); + const survival = (genData.popStillAlive | 0) / N; + const { gate: bot1, drop } = findBottleneck(rates); + state.lastBottleneck = bot1; + + // Crash heat for cars that died with checkPointsCount === bot1-1 + // (failed trying to reach bottleneck gate). Same events Heat mode paints. + const crash = crashCentroid(genData, bot1 - 1); + state.lastCrashCentroid = crash; + + // Encode + HNSW retrieve similar crash problems (ruvector crash-map index). + const crashVec = encodeCrashVec(genData); + if (crashVec) { + const hnswMsg = tryHnswLayout(crashVec, survival, genData); + if (hnswMsg && hnswMsg.indexOf('applied') !== -1) { + state.lastStatus = 'on · ' + hnswMsg; + state.lastSurvival = survival; + const nowCps = currentCps() || cps; + maybeRemember(survival, genData.fitness || 0, nowCps, crash); + archiveCrashToBridge(crashVec, survival, genData.fitness || 0, nowCps, genData, bot1); + return true; + } + // Always archive this gen's crash map + layout for future retrieval. + archiveCrashToBridge(crashVec, survival, genData.fitness || 0, cps, genData, bot1); + if (hnswMsg) { + // Fall through to local nudge/add/remove, but keep hnsw note. + state._hnswNote = hnswMsg; + } else { + state._hnswNote = null; + } + } else { + state._hnswNote = null; + } + + if (state.topoCooldown > 0) state.topoCooldown--; + + // Healthy population clearing the course — prefer pruning fluff over fidgeting. + if (rates[cpLen] > 0.55 && drop < 0.08) { + const next = cloneCps(cps); + const op = tryRemove(next, rates); + if (op && applyCps(next)) { + state.lastStatus = 'on · ' + op.msg; + state.lastSurvival = survival; + maybeRemember(survival, genData.fitness || 0, next, crash); + return true; + } + state.lastStatus = 'on · no bottleneck (population clearing gates)' + + (crash ? ' · heat n=' + crash.n : ''); + state.lastSurvival = survival; + maybeRemember(survival, genData.fitness || 0, cps, crash); + return false; + } + + if (state.lastSurvival != null && survival < state.lastSurvival - 0.04) { + state.badStreak++; + } else { + state.badStreak = 0; + } + state.lastSurvival = survival; + + const next = cloneCps(cps); + const b = bot1 - 1; + let op = null; + + if (state.badStreak >= 2 && state.baseline) { + // Hard reset toward baseline geometry (handles length mismatch too). + const restored = cloneCps(state.baseline); + state.badStreak = 0; + state.topoCooldown = TOPO_EVERY; + if (applyCps(restored)) { + state.lastStatus = 'on · survival dipped — restored baseline gates'; + maybeRemember(survival, genData.fitness || 0, restored, crash); + return true; + } + } + + // Priority: severe cliff → try insert (crash-aimed); else crash-aware nudge. + op = tryInsert(next, b, rates, drop, crash); + if (!op) op = tryNudge(next, Math.min(b, next.length - 1), crash); + // After a nudge on a mild cliff, also consider pruning elsewhere. + if (op && op.kind === 'nudge' && drop < 0.12 && next.length > MIN_GATES) { + const pruned = cloneCps(next); + const rm = tryRemove(pruned, rates); + if (rm) { + op = rm; + for (let i = next.length - 1; i >= 0; i--) next.pop(); + for (let i = 0; i < pruned.length; i++) next.push(pruned[i]); + } + } + + if (!op) { + state.lastStatus = 'on · no action'; + return false; + } + + if (!applyCps(next)) { + state.lastStatus = 'on · apply failed'; + return false; + } + + const pct = (rates[bot1] * 100).toFixed(0); + const prevPct = (rates[bot1 - 1] * 100).toFixed(0); + state.lastStatus = + 'on · bottleneck #' + bot1 + + ' (' + prevPct + '%→' + pct + '%) · ' + op.msg + + ' · n/a/r ' + state.nudgeCount + '/' + state.addCount + '/' + state.removeCount + + (state._hnswNote ? ' · ' + state._hnswNote : '') + + (crashVec ? ' · map archived' : ''); + maybeRemember(survival, genData.fitness || 0, next, crash); + if (crashVec) { + archiveCrashToBridge(crashVec, survival, genData.fitness || 0, next, genData, bot1); + } + return true; + } + + function maybeRemember(survival, fitness, cps, crash) { + const key = state.trackKey || trackKey(); + let mem = loadMemory(key) || { best: null, ring: [] }; + const entry = { + survival: +survival.toFixed(4), + fitness: +(+fitness || 0).toFixed(3), + cps: cloneCps(cps), + nGates: cps.length, + geometrySig: geometrySignature(), + crash: crash ? { x: +crash.x.toFixed(1), y: +crash.y.toFixed(1), n: crash.n | 0 } : null, + t: Date.now(), + }; + mem.ring.push(entry); + if (mem.ring.length > MEMORY_RING) mem.ring.shift(); + if (!mem.best || + entry.survival > mem.best.survival + 0.01 || + (Math.abs(entry.survival - mem.best.survival) < 0.01 && entry.fitness > mem.best.fitness)) { + mem.best = entry; + } + if (state.baseline) mem.baseline = cloneCps(state.baseline); + saveMemory(key, mem); + } + + function restoreBestIfAny() { + const key = trackKey(); + const mem = loadMemory(key); + if (!mem || !mem.best || !mem.best.cps || !mem.best.cps.length) return false; + // Memory is geometry-keyed; still reject if wall sig diverged. + if (mem.best.geometrySig && mem.best.geometrySig !== geometrySignature()) return false; + if (applyCps(mem.best.cps)) { + state.lastStatus = + 'on · restored best layout for this track (' + + mem.best.cps.length + ' gates, survival ' + + (mem.best.survival * 100).toFixed(0) + '%)'; + return true; + } + return false; + } + + function setEnabled(on) { + state.enabled = !!on; + if (!state.enabled) { + state.lastStatus = 'off'; + return state.enabled; + } + // Always align baseline with the *current* walls when enabling. + if (!state.baseline || trackStale()) { + onTrackChange(); + } + // Optional: restore a *same-track* remembered layout (not another preset). + if (!restoreBestIfAny()) { + state.lastStatus = 'on · watching pass rates + crash HNSW (baseline ' + + ((state.baseline && state.baseline.length) || 0) + ' gates)'; + } + return state.enabled; + } + + function resetToBaseline() { + // If the user switched tracks, baseline may still be the old triangle — + // refuse to paint it; recapture from whatever gates the preset installed. + if (!state.baseline || trackStale()) { + const ok = onTrackChange(); + state.lastStatus = state.enabled + ? (ok + ? ('on · baseline recaptured for current track (' + state.baseline.length + ' gates)') + : 'on · cannot reset (no gates)') + : 'off · baseline recaptured'; + return ok; + } + const ok = applyCps(state.baseline); + state.nudgeCount = 0; + state.addCount = 0; + state.removeCount = 0; + state.badStreak = 0; + state.lastBottleneck = -1; + state.topoCooldown = 0; + state.lastStatus = state.enabled + ? 'on · reset to baseline (' + state.baseline.length + ' gates)' + : 'off · baseline restored'; + return ok; + } + + function onGenEnd(genData) { + if (!state.enabled) return; + // Detect track switches that happened without onTrackChange (defensive). + if (trackStale()) onTrackChange(); + state.genSinceAdapt++; + if (state.genSinceAdapt < MIN_GENS_BETWEEN) return; + state.genSinceAdapt = 0; + try { + adaptOnce(genData); + } catch (e) { + console.warn('[adaptiveGates] onGenEnd failed', e); + state.lastStatus = 'on · error (see console)'; + } + } + + function getStatus() { + let crashMaps = 0; + try { + const b = window.__rvBridge; + if (b && b.info) crashMaps = (b.info().crashMaps && b.info().crashMaps.count) | 0; + else if (b && typeof b.crashMapCount === 'function') crashMaps = b.crashMapCount() | 0; + } catch (_) {} + return { + enabled: state.enabled, + status: state.lastStatus, + bottleneck: state.lastBottleneck, + nudgeCount: state.nudgeCount, + addCount: state.addCount, + removeCount: state.removeCount, + hnswHits: state.hnswHits, + hnswApplies: state.hnswApplies, + crashMaps: crashMaps, + survival: state.lastSurvival, + crash: state.lastCrashCentroid, + lastCrashHit: state.lastCrashHit, + hasBaseline: !!state.baseline, + baselineGates: state.baseline ? state.baseline.length : 0, + trackKey: state.trackKey, + }; + } + + global.AdaptiveGates = { + setEnabled: setEnabled, + isEnabled: function () { return !!state.enabled; }, + onGenEnd: onGenEnd, + onTrackChange: onTrackChange, + resetToBaseline: resetToBaseline, + captureBaseline: captureBaseline, + getStatus: getStatus, + geometrySignature: geometrySignature, + _state: state, + }; + + if (state.enabled) { + const boot = function () { + try { + if (currentCps() && currentCps().length) setEnabled(true); + } catch (_) {} + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { setTimeout(boot, 50); }); + } else { + setTimeout(boot, 50); + } + } +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/AI-Car-Racer/buttonResponse.js b/AI-Car-Racer/buttonResponse.js index dc1c5a3..2be105b 100644 --- a/AI-Car-Racer/buttonResponse.js +++ b/AI-Car-Racer/buttonResponse.js @@ -424,22 +424,16 @@ function setConservativeInit(value){ conservativeInit = Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 0; try { localStorage.setItem("conservativeInit", String(conservativeInit)); } catch (_) {} } -// Training-phase presets. Each entry sets the four knobs that dominate -// training feel: N (population size), simSpeed (stride schedule kicks in -// above 2×), seconds (per-generation evaluation window), mutateValue -// (GA variance). Values are step-aligned to the slider granularity so -// the DOM reflects them exactly. -// fresh — random brains: small population, honest 2× (stride=1), high -// variance, short gens. Cheapest path from garbage to "turns". -// grind — elite drives laps consistently: grind raw generation count -// via high simSpeed. Fitness noise from stride=3 is tolerated -// because elite always survives. -// polish — refine a competent brain: wider population, low variance, -// longer gens for multi-lap evaluation, honest 2×. +// Training-phase presets. Each entry sets the knobs that dominate training +// feel: N, simSpeed, seconds, mutateValue, conservativeInit. Values are +// step-aligned to the slider granularity so the DOM reflects them exactly. +// fresh — cold / early: honest 2×, conservative wall-reflex bias, explore +// grind — elite exists: farm generations + grow the ruvector archive +// polish — refine lap times: wider pop, low variance, longer gens const TRAINING_PRESETS = { - fresh: { N: 500, simSpeed: 2, seconds: 10, mutate: 0.30 }, - grind: { N: 500, simSpeed: 20, seconds: 15, mutate: 0.20 }, - polish: { N: 1000, simSpeed: 2, seconds: 25, mutate: 0.05 } + fresh: { N: 500, simSpeed: 2, seconds: 15, mutate: 0.25, conservativeInit: 0.70 }, + grind: { N: 600, simSpeed: 20, seconds: 15, mutate: 0.18, conservativeInit: 0.50 }, + polish: { N: 800, simSpeed: 2, seconds: 25, mutate: 0.05, conservativeInit: 0.30 } }; function applyTrainingPreset(name){ @@ -452,6 +446,9 @@ function applyTrainingPreset(name){ setSeconds(p.seconds); setMutateValue(p.mutate); setSimSpeed(p.simSpeed); + if (typeof p.conservativeInit === 'number' && typeof setConservativeInit === 'function') { + setConservativeInit(p.conservativeInit); + } // Reflect values in the DOM so the user can see what changed. const bs = document.getElementById('batchSizeInput'); if (bs){ bs.value = p.N; document.getElementById('batchSizeOutput').value = 'Batch Size: ' + p.N; } @@ -459,6 +456,12 @@ function applyTrainingPreset(name){ if (se){ se.value = p.seconds; document.getElementById('secondsOutput').value = 'Round Length: ' + p.seconds; } const mv = document.getElementById('mutateValueInput'); if (mv){ mv.value = p.mutate; document.getElementById('mutateValueOutput').value = 'Variance: ' + p.mutate; } + const ci = document.getElementById('conservativeInitInput'); + if (ci && typeof p.conservativeInit === 'number'){ + ci.value = p.conservativeInit; + const co = document.getElementById('conservativeInitOutput'); + if (co) co.value = 'Conservative Init: ' + p.conservativeInit; + } const ss = document.getElementById('simSpeedInput'); if (ss){ ss.value = String(p.simSpeed); } } diff --git a/AI-Car-Racer/crashMapCodec.js b/AI-Car-Racer/crashMapCodec.js new file mode 100644 index 0000000..f1afc21 --- /dev/null +++ b/AI-Car-Racer/crashMapCodec.js @@ -0,0 +1,97 @@ +// crashMapCodec.js — encode gen-end death positions into a fixed grid vector +// for HNSW / cosine search. Classic script + ES-module friendly (no deps). +// +// Grid is 16×9 over the 3200×1800 canvas (CRASH_DIM = 144). Cell values are +// log1p(count) then L2-normalised so cosine distance is well-behaved. +// +// Surface: window.CrashMapCodec (classic) and named exports when imported. + +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) { + module.exports = api; + } + if (typeof root !== 'undefined') root.CrashMapCodec = api; + // ES module re-export surface when this file is imported as a module via + // dynamic import of a thin wrapper — bridge inlines the same constants + // for no-build static import simplicity; keep this file as the source of + // truth for adaptiveGates (classic). +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + const CRASH_GW = 16; + const CRASH_GH = 9; + const CRASH_DIM = CRASH_GW * CRASH_GH; + const DEFAULT_W = 3200; + const DEFAULT_H = 1800; + + /** + * @param {Float32Array|null} popDeathXY length 2N, NaN for alive + * @param {number} N + * @param {number} [canvasW] + * @param {number} [canvasH] + * @returns {Float32Array|null} L2-normalised CRASH_DIM vector, or null if no deaths + */ + function encodeDeathMap(popDeathXY, N, canvasW, canvasH) { + if (!popDeathXY || !N) return null; + const W = canvasW || (typeof canvas !== 'undefined' && canvas && canvas.width) || DEFAULT_W; + const H = canvasH || (typeof canvas !== 'undefined' && canvas && canvas.height) || DEFAULT_H; + const grid = new Float32Array(CRASH_DIM); + let deaths = 0; + for (let i = 0; i < N; i++) { + const x = popDeathXY[i * 2]; + const y = popDeathXY[i * 2 + 1]; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + let gx = Math.floor((x / W) * CRASH_GW); + let gy = Math.floor((y / H) * CRASH_GH); + if (gx < 0) gx = 0; else if (gx >= CRASH_GW) gx = CRASH_GW - 1; + if (gy < 0) gy = 0; else if (gy >= CRASH_GH) gy = CRASH_GH - 1; + grid[gy * CRASH_GW + gx] += 1; + deaths++; + } + if (deaths < 3) return null; + // log1p compresses hot cells so one pile-up doesn't dominate the embedding + let sumSq = 0; + for (let i = 0; i < CRASH_DIM; i++) { + const v = Math.log1p(grid[i]); + grid[i] = v; + sumSq += v * v; + } + const norm = Math.sqrt(sumSq); + if (norm < 1e-9) return null; + const inv = 1 / norm; + for (let i = 0; i < CRASH_DIM; i++) grid[i] *= inv; + return grid; + } + + /** Decode a (possibly unnormalised) map back to a dense GH×GW count-ish grid for viz. */ + function decodeToGrid(vec) { + const out = new Float32Array(CRASH_DIM); + if (!vec || vec.length !== CRASH_DIM) return out; + for (let i = 0; i < CRASH_DIM; i++) out[i] = Math.max(0, vec[i]); + return out; + } + + function causeHistogram(popDeathCauses, N) { + const h = { headOn: 0, side: 0, slide: 0, stalled: 0, alive: 0 }; + if (!popDeathCauses || !N) return h; + for (let i = 0; i < N; i++) { + const c = popDeathCauses[i] | 0; + if (c === 0) h.headOn++; + else if (c === 1) h.side++; + else if (c === 2) h.slide++; + else if (c === 3) h.stalled++; + else h.alive++; + } + return h; + } + + return { + CRASH_GW: CRASH_GW, + CRASH_GH: CRASH_GH, + CRASH_DIM: CRASH_DIM, + encodeDeathMap: encodeDeathMap, + decodeToGrid: decodeToGrid, + causeHistogram: causeHistogram, + }; +}); diff --git a/AI-Car-Racer/demoPresentation.js b/AI-Car-Racer/demoPresentation.js new file mode 100644 index 0000000..73b63ba --- /dev/null +++ b/AI-Car-Racer/demoPresentation.js @@ -0,0 +1,1082 @@ +// demoPresentation.js — cinematic presentation layer for VectorVroom. +// Classic script (no modules). Consumes the same Float32Array pose snapshots +// the worker already posts; never touches the sim loop. +// +// Features: +// • Road cache blit (skip full road repaint every rAF during training) +// • Rank-colored swarm + Top-K quads + pack dots +// • Champion outline + sensor rays + motion trails +// • Crash heatmap (where cars die) +// • Follow-best camera +// • Cinematic HUD (gen / alive / fitness / story line) +// • Optional 3D perspective view of the same 2D world +// • Demo mode: auto-start, follow, story beats +// +// URL flags: ?demo=1 ?follow=1 ?view=3d ?trails=0 ?heatmap=0 +// Keyboard: F follow · 3 3D · T trails · H heatmap · D demo · 0 reset cam + +(function (global) { + 'use strict'; + + const params = new URLSearchParams(location.search); + const flag = (k, defTrue) => { + const v = params.get(k); + if (v === null) return !!defTrue; + if (v === '0' || v === 'false') return false; + return true; + }; + + const state = { + ready: false, + // feature toggles + followBest: flag('follow', false) || flag('demo', false), + view3d: params.get('view') === '3d', + trails: flag('trails', true), + heatmap: flag('heatmap', true), + rankColors: true, + // camera + camScale: 1, + camX: 0, + camY: 0, + targetScale: 1, + targetX: 0, + targetY: 0, + followZoom: 2.2, + // road cache + roadCache: null, + roadCacheDirty: true, + roadCacheKey: '', + // trails: ring of {x,y} for champion + trail: [], + trailMax: 48, + // heatmap grid + heatW: 160, + heatH: 90, + heat: null, + heatCanvas: null, + prevDamaged: null, + // HUD + hudEl: null, + storyEl: null, + controlsEl: null, + lastFitness: 0, + lastGen: -1, + peakFitness: 0, + genStartAlive: 0, + story: '', + storyUntil: 0, + // demo mode + demoMode: flag('demo', false), + demoStep: 0, + demoStarted: false, + // 3D orbital camera — high 3/4 angle so the track reads as a flat + // loop with depth, not a thin edge-on strip. + elev: 0.95, // elevation from horizontal (~54°) — mostly top-down + yaw: 0.55, // orbit around vertical (~31°) — see both track axes + baseDist: 2400, // eye distance from look-at at zoom=1 + focal: 1700, // mild perspective (higher = flatter / more isometric) + wallHeight: 48, + carHeight: 20, + view3dFit: 1.08, // slight zoom-in so the loop fills the frame + followZoom3d: 1.55, // keep corridor context — avoid edge-on crop + }; + + // ------------------------------------------------------------------ setup + function init() { + if (state.ready) return; + state.ready = true; + state.heat = new Float32Array(state.heatW * state.heatH); + ensureHud(); + ensureControls(); + bindKeys(); + if (state.demoMode) { + // Defer so phase-4 layout + worker exist. + setTimeout(startDemoMode, 80); + } + syncControlButtons(); + setStory(state.demoMode + ? 'Demo mode — watch a swarm learn to drive.' + : 'Train a neural net to race. Press D for demo mode.', 6000); + } + + function ensureHud() { + if (state.hudEl) return; + const host = document.getElementById('canvasDiv') || document.body; + const el = document.createElement('div'); + el.id = 'cinema-hud'; + el.setAttribute('aria-live', 'polite'); + el.innerHTML = + '
' + + 'Gen 0' + + 'Alive ' + + 'Best ' + + 'Laps 0' + + 'Sim ' + + '
' + + '
'; + host.appendChild(el); + state.hudEl = el; + state.storyEl = el.querySelector('[data-c="story"]'); + } + + function ensureControls() { + if (state.controlsEl) return; + const host = document.getElementById('canvasDiv') || document.body; + const el = document.createElement('div'); + el.id = 'cinema-controls'; + el.innerHTML = + '' + + '' + + '' + + '' + + ''; + el.addEventListener('click', (ev) => { + const btn = ev.target.closest('[data-act]'); + if (!btn) return; + const act = btn.getAttribute('data-act'); + if (act === 'follow') setFollow(!state.followBest); + else if (act === 'view3d') setView3d(!state.view3d); + else if (act === 'trails') { state.trails = !state.trails; if (!state.trails) state.trail.length = 0; } + else if (act === 'heatmap') state.heatmap = !state.heatmap; + else if (act === 'demo') startDemoMode(); + syncControlButtons(); + }); + host.appendChild(el); + state.controlsEl = el; + } + + function syncControlButtons() { + if (!state.controlsEl) return; + const map = { + follow: state.followBest, + view3d: state.view3d, + trails: state.trails, + heatmap: state.heatmap, + demo: state.demoMode, + }; + state.controlsEl.querySelectorAll('[data-act]').forEach((btn) => { + const on = !!map[btn.getAttribute('data-act')]; + btn.classList.toggle('is-on', on); + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + } + + function bindKeys() { + window.addEventListener('keydown', (e) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + const t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return; + const k = e.key; + if (k === 'f' || k === 'F') { setFollow(!state.followBest); syncControlButtons(); } + else if (k === '3') { setView3d(!state.view3d); syncControlButtons(); } + else if (k === 't' || k === 'T') { state.trails = !state.trails; if (!state.trails) state.trail.length = 0; syncControlButtons(); } + else if (k === 'h' || k === 'H') { state.heatmap = !state.heatmap; syncControlButtons(); } + else if (k === 'd' || k === 'D') { startDemoMode(); syncControlButtons(); } + else if (k === '0') { resetCamera(); } + }); + } + + // --------------------------------------------------------------- road cache + function useRoadCache() { + // Cache only during training when the editor is locked — during track + // editing every drag needs a live redraw. + try { + if (typeof phase !== 'undefined' && phase !== 4) return false; + if (typeof road !== 'undefined' && road && road.roadEditor && road.roadEditor.editMode) return false; + return true; + } catch (_) { return false; } + } + + function invalidateRoad() { + state.roadCacheDirty = true; + state.roadCacheKey = ''; + } + + function roadKey() { + try { + const re = road.roadEditor; + // Lengths + first/last points are enough to catch edits & preset loads. + const p = re.points, q = re.points2, c = re.checkPointListEditor; + const a = p && p[0] ? (p[0].x | 0) + ',' + (p[0].y | 0) : ''; + const b = q && q[0] ? (q[0].x | 0) + ',' + (q[0].y | 0) : ''; + return (p ? p.length : 0) + '|' + (q ? q.length : 0) + '|' + (c ? c.length : 0) + '|' + a + '|' + b + '|' + canvas.width + 'x' + canvas.height; + } catch (_) { return String(Date.now()); } + } + + function ensureRoadCache() { + if (!useRoadCache()) return false; + const key = roadKey(); + if (!state.roadCacheDirty && state.roadCache && state.roadCacheKey === key) return true; + const w = canvas.width, h = canvas.height; + if (!state.roadCache || state.roadCache.width !== w || state.roadCache.height !== h) { + state.roadCache = document.createElement('canvas'); + state.roadCache.width = w; + state.roadCache.height = h; + } + const rctx = state.roadCache.getContext('2d'); + rctx.fillStyle = '#15161a'; + rctx.fillRect(0, 0, w, h); + // Paint road geometry into the cache without touching the live canvas. + paintRoadTo(rctx); + state.roadCacheDirty = false; + state.roadCacheKey = key; + return true; + } + + function paintRoadTo(c) { + // Prefer roadEditor's public paint path if present; else reimplement walls. + try { + if (road && road.roadEditor && typeof road.roadEditor.paintTo === 'function') { + road.roadEditor.paintTo(c); + return; + } + } catch (_) {} + // Fallback: minimal walls + checkpoints (no edit handles). + try { + const re = road.roadEditor; + const wallW = 12, checkW = 8; + const strokeLoop = (pts) => { + if (!pts || pts.length < 2) return; + c.beginPath(); + c.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) c.lineTo(pts[i].x, pts[i].y); + c.closePath(); + c.stroke(); + }; + c.strokeStyle = '#ffffff'; + c.lineWidth = wallW; + c.lineJoin = 'round'; + strokeLoop(re.points); + strokeLoop(re.points2); + c.strokeStyle = '#58E05D'; + c.lineWidth = checkW; + (re.checkPointListEditor || []).forEach((seg) => { + if (!seg || !seg[0] || !seg[1]) return; + c.beginPath(); + c.moveTo(seg[0].x, seg[0].y); + c.lineTo(seg[1].x, seg[1].y); + c.stroke(); + }); + if (typeof re.drawStartPos === 'function') re.drawStartPos(startInfo, c); + } catch (e) { + console.warn('[demo] paintRoadTo failed', e); + } + } + + function blitRoad(targetCtx) { + if (!ensureRoadCache()) { + // Fall back to live redraw. + if (road && typeof road.draw === 'function') road.draw(targetCtx); + return; + } + // Background is in the cache; just blit. + targetCtx.drawImage(state.roadCache, 0, 0); + } + + // --------------------------------------------------------------- camera + function setFollow(on) { + state.followBest = !!on; + if (!on) { + state.targetScale = 1; + // Ease back toward full track. + } else { + state.targetScale = state.followZoom; + } + } + + function resetCamera() { + state.followBest = false; + state.targetScale = 1; + state.targetX = canvas.width / 2; + state.targetY = canvas.height / 2; + state.camScale = 1; + state.camX = canvas.width / 2; + state.camY = canvas.height / 2; + syncControlButtons(); + } + + function setView3d(on) { + state.view3d = !!on; + if (on && state.followBest) state.targetScale = state.followZoom3d; + else if (on) state.targetScale = state.view3dFit; + } + + function updateCamera(best) { + const w = canvas.width, h = canvas.height; + if (state.followBest && best && Number.isFinite(best.x) && Number.isFinite(best.y)) { + state.targetX = best.x; + state.targetY = best.y; + state.targetScale = state.view3d ? state.followZoom3d : state.followZoom; + } else { + state.targetX = w / 2; + state.targetY = h / 2; + state.targetScale = state.view3d ? state.view3dFit : 1; + } + // Smooth follow (critically damped-ish lerp). + const a = 0.12; + state.camX += (state.targetX - state.camX) * a; + state.camY += (state.targetY - state.camY) * a; + state.camScale += (state.targetScale - state.camScale) * a; + } + + /** Apply 2D pan/zoom (not used in pure 3D projection path). */ + function beginCamera(ctx) { + if (state.view3d) return false; // 3D path does its own projection + if (Math.abs(state.camScale - 1) < 0.01 && + Math.abs(state.camX - canvas.width / 2) < 2 && + Math.abs(state.camY - canvas.height / 2) < 2) { + return false; + } + ctx.save(); + ctx.translate(canvas.width / 2, canvas.height / 2); + ctx.scale(state.camScale, state.camScale); + ctx.translate(-state.camX, -state.camY); + return true; + } + + function endCamera(ctx, applied) { + if (applied) ctx.restore(); + } + + // --------------------------------------------------------------- 3D project + // Orbital camera around look-at (camX, camY) on the ground plane. + // World: x right, y down (canvas), z up. Eye sits at (yaw, elev, dist) + // and looks at the target — classic 3/4 racing angle, not a pure pitch. + function project(wx, wy, wz) { + wz = wz || 0; + const elev = state.elev; + const yaw = state.yaw; + // Zoom = move the eye closer (higher camScale → smaller dist). + const dist = state.baseDist / Math.max(0.35, state.camScale); + const cosE = Math.cos(elev); + const sinE = Math.sin(elev); + const cosY = Math.cos(yaw); + const sinY = Math.sin(yaw); + + // Eye position relative to look-at (above and toward +Y / bottom of canvas). + const eyeX = sinY * cosE * dist; + const eyeY = cosY * cosE * dist; + const eyeZ = sinE * dist; + + // Vector from eye to the world point. + const px = (wx - state.camX) - eyeX; + const py = (wy - state.camY) - eyeY; + const pz = wz - eyeZ; + + // Orthonormal camera basis: forward (eye → look-at), right, up. + // worldUp = (0,0,1). right = normalize(worldUp × forward) so +X world + // maps to screen-right (the opposite cross product mirrored the scene). + const fl = Math.hypot(eyeX, eyeY, eyeZ) || 1; + const fwx = -eyeX / fl; + const fwy = -eyeY / fl; + const fwz = -eyeZ / fl; + // cross(up, fw) = (-fwy, fwx, 0) + let rgtX = -fwy, rgtY = fwx, rgtZ = 0; + const rl = Math.hypot(rgtX, rgtY) || 1; + rgtX /= rl; rgtY /= rl; + // camUp = forward × right (keeps a right-handed view basis) + const upX = fwy * rgtZ - fwz * rgtY; + const upY = fwz * rgtX - fwx * rgtZ; + const upZ = fwx * rgtY - fwy * rgtX; + + // View space: +X right, +Y up, +Z into the scene (along forward). + const viewX = px * rgtX + py * rgtY + pz * rgtZ; + const viewY = px * upX + py * upY + pz * upZ; + const viewZ = px * fwx + py * fwy + pz * fwz; + if (viewZ < 40) return null; + + const f = state.focal / viewZ; + return { + x: canvas.width / 2 + viewX * f, + y: canvas.height / 2 - viewY * f, // screen Y grows downward + f: f, + }; + } + + function drawProjectedRoad(ctx) { + // Ground wash + ctx.fillStyle = '#15161a'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Soft ground quad (track bounding box) + const pad = 40; + const corners = [ + project(pad, pad, 0), + project(canvas.width - pad, pad, 0), + project(canvas.width - pad, canvas.height - pad, 0), + project(pad, canvas.height - pad, 0), + ]; + if (corners.every(Boolean)) { + ctx.beginPath(); + ctx.moveTo(corners[0].x, corners[0].y); + for (let i = 1; i < 4; i++) ctx.lineTo(corners[i].x, corners[i].y); + ctx.closePath(); + ctx.fillStyle = '#1a1c22'; + ctx.fill(); + } + + try { + const re = road.roadEditor; + drawExtrudedLoop(ctx, re.points, state.wallHeight, '#e8e8ec'); + drawExtrudedLoop(ctx, re.points2, state.wallHeight, '#e8e8ec'); + // Checkpoints as flat green ribbons + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(88, 224, 93, 0.85)'; + (re.checkPointListEditor || []).forEach((seg) => { + if (!seg || !seg[0] || !seg[1]) return; + const a = project(seg[0].x, seg[0].y, 1); + const b = project(seg[1].x, seg[1].y, 1); + if (!a || !b) return; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + }); + } catch (_) {} + } + + function drawExtrudedLoop(ctx, pts, h, color) { + if (!pts || pts.length < 2) return; + ctx.strokeStyle = color; + ctx.fillStyle = 'rgba(200,200,210,0.12)'; + ctx.lineWidth = 1.5; + for (let i = 0; i < pts.length; i++) { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + const a0 = project(a.x, a.y, 0); + const a1 = project(a.x, a.y, h); + const b0 = project(b.x, b.y, 0); + const b1 = project(b.x, b.y, h); + if (!a0 || !a1 || !b0 || !b1) continue; + // Wall face + ctx.beginPath(); + ctx.moveTo(a0.x, a0.y); + ctx.lineTo(b0.x, b0.y); + ctx.lineTo(b1.x, b1.y); + ctx.lineTo(a1.x, a1.y); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + // Top rim + ctx.beginPath(); + let started = false; + for (let i = 0; i < pts.length; i++) { + const p = project(pts[i].x, pts[i].y, h); + if (!p) continue; + if (!started) { ctx.moveTo(p.x, p.y); started = true; } + else ctx.lineTo(p.x, p.y); + } + if (started) { + ctx.closePath(); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.stroke(); + } + } + + function drawProjectedCar(ctx, x, y, angle, color, alpha, h) { + const rad = Math.hypot(30, 50) / 2; + const alphaA = Math.atan2(30, 50); + const corners = [ + [angle - alphaA, rad], + [angle + alphaA, rad], + [Math.PI + angle + alphaA, rad], + [Math.PI + angle - alphaA, rad], + ]; + const top = []; + const bot = []; + for (let i = 0; i < 4; i++) { + const wx = x - Math.sin(corners[i][0]) * corners[i][1]; + const wy = y - Math.cos(corners[i][0]) * corners[i][1]; + const p0 = project(wx, wy, 0); + const p1 = project(wx, wy, h || state.carHeight); + if (!p0 || !p1) return; + bot.push(p0); + top.push(p1); + } + ctx.globalAlpha = alpha; + // Side faces (simple — back-to-front not sorted; fine at this scale) + ctx.fillStyle = color; + for (let i = 0; i < 4; i++) { + const j = (i + 1) % 4; + ctx.beginPath(); + ctx.moveTo(bot[i].x, bot[i].y); + ctx.lineTo(bot[j].x, bot[j].y); + ctx.lineTo(top[j].x, top[j].y); + ctx.lineTo(top[i].x, top[i].y); + ctx.closePath(); + ctx.fill(); + } + // Roof + ctx.beginPath(); + ctx.moveTo(top[0].x, top[0].y); + for (let i = 1; i < 4; i++) ctx.lineTo(top[i].x, top[i].y); + ctx.closePath(); + ctx.fillStyle = color; + ctx.globalAlpha = Math.min(1, alpha + 0.15); + ctx.fill(); + ctx.globalAlpha = 1; + } + + // --------------------------------------------------------------- colors + /** Rank t in [0,1] (0=best) → amber→lime→cyan */ + function rankColor(t, alpha) { + // t=0 best (lime-gold), t=1 pack (muted amber) + const r = Math.round(227 + (80 - 227) * (1 - t) * 0.55); + const g = Math.round(138 + (220 - 138) * (1 - t)); + const b = Math.round(15 + (120 - 15) * (1 - t) * 0.4); + if (alpha == null) return 'rgb(' + r + ',' + g + ',' + b + ')'; + return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'; + } + + const CHAMPION = '#FFE566'; + const DEAD = 'rgba(120,120,130,0.35)'; + + // --------------------------------------------------------------- heatmap + function heatIndex(x, y) { + const gx = Math.max(0, Math.min(state.heatW - 1, (x / canvas.width * state.heatW) | 0)); + const gy = Math.max(0, Math.min(state.heatH - 1, (y / canvas.height * state.heatH) | 0)); + return gy * state.heatW + gx; + } + + function depositHeat(x, y, amount) { + const r = 2; + const cx = (x / canvas.width * state.heatW); + const cy = (y / canvas.height * state.heatH); + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + const gx = (cx + dx) | 0; + const gy = (cy + dy) | 0; + if (gx < 0 || gy < 0 || gx >= state.heatW || gy >= state.heatH) continue; + const w = 1 - Math.sqrt(dx * dx + dy * dy) / (r + 1); + if (w > 0) state.heat[gy * state.heatW + gx] += amount * w; + } + } + } + + function decayHeat() { + const h = state.heat; + for (let i = 0; i < h.length; i++) h[i] *= 0.992; + } + + function observeDeaths(snap) { + if (!state.heatmap || !snap || !snap.positions) return; + const N = snap.N | 0; + const pos = snap.positions; + if (!state.prevDamaged || state.prevDamaged.length !== N) { + state.prevDamaged = new Uint8Array(N); + for (let i = 0; i < N; i++) state.prevDamaged[i] = pos[i * 5 + 3] ? 1 : 0; + return; + } + for (let i = 0; i < N; i++) { + const d = pos[i * 5 + 3] ? 1 : 0; + if (d && !state.prevDamaged[i]) { + depositHeat(pos[i * 5], pos[i * 5 + 1], 1.4); + } + state.prevDamaged[i] = d; + } + // Soft continuous decay so old crash zones fade over a generation. + if ((snap.frameCount | 0) % 8 === 0) decayHeat(); + } + + function drawHeatmap(ctx) { + if (!state.heatmap) return; + let max = 0.001; + const h = state.heat; + for (let i = 0; i < h.length; i++) if (h[i] > max) max = h[i]; + if (max < 0.05) return; + + if (!state.heatCanvas) { + state.heatCanvas = document.createElement('canvas'); + state.heatCanvas.width = state.heatW; + state.heatCanvas.height = state.heatH; + } + const hc = state.heatCanvas; + const hctx = hc.getContext('2d'); + const img = hctx.createImageData(state.heatW, state.heatH); + const data = img.data; + for (let i = 0; i < h.length; i++) { + const t = Math.min(1, h[i] / max); + if (t < 0.04) continue; + const o = i * 4; + // ember gradient + data[o] = 255; + data[o + 1] = Math.round(40 + 100 * (1 - t)); + data[o + 2] = 20; + data[o + 3] = Math.round(30 + 150 * t); + } + hctx.putImageData(img, 0, 0); + ctx.save(); + ctx.globalAlpha = 0.55; + ctx.imageSmoothingEnabled = true; + if (state.view3d) { + // Approximate: draw as ground-projected textured quad is hard without + // WebGL — sample cells and draw soft dots in projected space instead. + ctx.globalAlpha = 0.45; + for (let gy = 0; gy < state.heatH; gy += 1) { + for (let gx = 0; gx < state.heatW; gx += 1) { + const v = h[gy * state.heatW + gx]; + if (v < max * 0.08) continue; + const wx = (gx + 0.5) / state.heatW * canvas.width; + const wy = (gy + 0.5) / state.heatH * canvas.height; + const p = project(wx, wy, 0); + if (!p) continue; + const t = Math.min(1, v / max); + const r = 4 * p.f * t + 1; + ctx.fillStyle = 'rgba(255,' + Math.round(40 + 80 * (1 - t)) + ',20,' + (0.15 + 0.45 * t) + ')'; + ctx.beginPath(); + ctx.arc(p.x, p.y, r, 0, Math.PI * 2); + ctx.fill(); + } + } + } else { + ctx.drawImage(hc, 0, 0, canvas.width, canvas.height); + } + ctx.restore(); + } + + // --------------------------------------------------------------- trails + function pushTrail(best) { + if (!state.trails || !best || best.damaged) return; + const last = state.trail[state.trail.length - 1]; + if (last && Math.hypot(best.x - last.x, best.y - last.y) < 3) return; + state.trail.push({ x: best.x, y: best.y }); + if (state.trail.length > state.trailMax) state.trail.shift(); + } + + function clearTrail() { state.trail.length = 0; } + + function drawTrail(ctx) { + if (!state.trails || state.trail.length < 2) return; + ctx.save(); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + for (let i = 1; i < state.trail.length; i++) { + const a = state.trail[i - 1]; + const b = state.trail[i]; + const t = i / state.trail.length; + ctx.strokeStyle = 'rgba(255, 229, 102,' + (0.08 + 0.45 * t) + ')'; + ctx.lineWidth = 2 + 6 * t; + if (state.view3d) { + const pa = project(a.x, a.y, 2); + const pb = project(b.x, b.y, 2); + if (!pa || !pb) continue; + ctx.beginPath(); + ctx.moveTo(pa.x, pa.y); + ctx.lineTo(pb.x, pb.y); + ctx.lineWidth = (2 + 6 * t) * ((pa.f + pb.f) * 0.5); + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + } + ctx.restore(); + } + + // --------------------------------------------------------------- swarm + const _CAR_RAD = Math.hypot(30, 50) / 2; + const _CAR_ALPHA = Math.atan2(30, 50); + + function drawCarQuad2d(ctx, x, y, angle) { + ctx.beginPath(); + ctx.moveTo(x - Math.sin(angle - _CAR_ALPHA) * _CAR_RAD, y - Math.cos(angle - _CAR_ALPHA) * _CAR_RAD); + ctx.lineTo(x - Math.sin(angle + _CAR_ALPHA) * _CAR_RAD, y - Math.cos(angle + _CAR_ALPHA) * _CAR_RAD); + ctx.lineTo(x - Math.sin(Math.PI + angle + _CAR_ALPHA) * _CAR_RAD, y - Math.cos(Math.PI + angle + _CAR_ALPHA) * _CAR_RAD); + ctx.lineTo(x - Math.sin(Math.PI + angle - _CAR_ALPHA) * _CAR_RAD, y - Math.cos(Math.PI + angle - _CAR_ALPHA) * _CAR_RAD); + ctx.fill(); + } + + function drawSwarm(ctx, snap) { + if (!snap || !snap.positions) return; + const N = snap.N | 0; + const pos = snap.positions; + const topK = (typeof RENDER_TOP_K === 'number') ? RENDER_TOP_K : 32; + const full = (typeof FULL_RENDER !== 'undefined') && FULL_RENDER; + + observeDeaths(snap); + + // 3D road/heat/trail already painted in beginFrame; 2D paints them here + // so they sit under the cars and ride the follow-camera transform. + if (!state.view3d) { + drawHeatmap(ctx); + drawTrail(ctx); + } else { + // Refresh trail/heat under the latest poses (road already drawn). + drawHeatmap(ctx); + drawTrail(ctx); + } + + // Collect live indices sorted by fitness desc. + const liveIdx = []; + for (let i = 0; i < N; i++) { + if (pos[i * 5 + 3] === 0) liveIdx.push(i); + } + liveIdx.sort((a, b) => pos[b * 5 + 4] - pos[a * 5 + 4]); + + if (full) { + for (let i = 0; i < N; i++) { + const base = i * 5; + const dead = pos[base + 3] !== 0; + const col = dead ? DEAD : rankColor(0.6, 0.35); + if (state.view3d) { + if (!dead) drawProjectedCar(ctx, pos[base], pos[base + 1], pos[base + 2], col, 0.35, state.carHeight * 0.7); + } else { + ctx.fillStyle = col; + ctx.globalAlpha = dead ? 0.25 : 0.35; + drawCarQuad2d(ctx, pos[base], pos[base + 1], pos[base + 2]); + ctx.globalAlpha = 1; + } + } + return; + } + + const kDraw = Math.min(topK, liveIdx.length); + + // Pack as dots + if (liveIdx.length > kDraw) { + if (state.view3d) { + for (let i = kDraw; i < liveIdx.length; i++) { + const idx = liveIdx[i]; + const rankT = i / Math.max(1, liveIdx.length - 1); + const p = project(pos[idx * 5], pos[idx * 5 + 1], 0); + if (!p) continue; + ctx.fillStyle = rankColor(rankT, 0.55); + const r = Math.max(1.2, 2.2 * p.f); + ctx.beginPath(); + ctx.arc(p.x, p.y, r, 0, Math.PI * 2); + ctx.fill(); + } + } else { + ctx.beginPath(); + for (let i = kDraw; i < liveIdx.length; i++) { + const idx = liveIdx[i]; + ctx.rect(pos[idx * 5] - 2, pos[idx * 5 + 1] - 2, 4, 4); + } + // single fill; color mid-rank + ctx.fillStyle = rankColor(0.55, 0.55); + ctx.fill(); + } + } + + // Top-K quads with rank colors + for (let i = 0; i < kDraw; i++) { + const idx = liveIdx[i]; + const rankT = kDraw <= 1 ? 0 : i / (kDraw - 1); + const col = rankColor(rankT, 0.7); + const x = pos[idx * 5], y = pos[idx * 5 + 1], ang = pos[idx * 5 + 2]; + if (state.view3d) { + drawProjectedCar(ctx, x, y, ang, col, 0.75, state.carHeight); + } else { + ctx.globalAlpha = 0.7; + ctx.fillStyle = col; + drawCarQuad2d(ctx, x, y, ang); + ctx.globalAlpha = 1; + } + } + } + + function drawChampion(ctx, best) { + if (!best) return; + pushTrail(best); + const x = best.x, y = best.y, ang = best.angle; + if (state.view3d) { + drawProjectedCar(ctx, x, y, ang, CHAMPION, 1, state.carHeight * 1.15); + // Sensor rays + if (best.sensor && best.sensor.rays && best.sensor.rays.length) { + for (let i = 0; i < best.sensor.rays.length; i++) { + const ray = best.sensor.rays[i]; + const reading = best.sensor.readings[i]; + const end = reading ? reading : ray[1]; + const a = project(ray[0].x, ray[0].y, state.carHeight * 0.5); + const b = project(end.x, end.y, 2); + if (!a || !b) continue; + ctx.beginPath(); + ctx.strokeStyle = 'rgba(255,230,80,0.9)'; + ctx.lineWidth = 2 * a.f; + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + } + // Halo + const p = project(x, y, state.carHeight + 4); + if (p) { + ctx.beginPath(); + ctx.strokeStyle = 'rgba(255,229,102,0.55)'; + ctx.lineWidth = 2; + ctx.arc(p.x, p.y, 18 * p.f, 0, Math.PI * 2); + ctx.stroke(); + } + return; + } + // 2D champion + ctx.save(); + ctx.shadowColor = 'rgba(255,229,102,0.85)'; + ctx.shadowBlur = 18; + ctx.fillStyle = CHAMPION; + drawCarQuad2d(ctx, x, y, ang); + ctx.shadowBlur = 0; + // Outline + ctx.strokeStyle = '#fff8c8'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(x - Math.sin(ang - _CAR_ALPHA) * _CAR_RAD, y - Math.cos(ang - _CAR_ALPHA) * _CAR_RAD); + ctx.lineTo(x - Math.sin(ang + _CAR_ALPHA) * _CAR_RAD, y - Math.cos(ang + _CAR_ALPHA) * _CAR_RAD); + ctx.lineTo(x - Math.sin(Math.PI + ang + _CAR_ALPHA) * _CAR_RAD, y - Math.cos(Math.PI + ang + _CAR_ALPHA) * _CAR_RAD); + ctx.lineTo(x - Math.sin(Math.PI + ang - _CAR_ALPHA) * _CAR_RAD, y - Math.cos(Math.PI + ang - _CAR_ALPHA) * _CAR_RAD); + ctx.closePath(); + ctx.stroke(); + ctx.restore(); + + if (best.sensor && best.sensor.rays && best.sensor.rays.length) { + for (let i = 0; i < best.sensor.rays.length; i++) { + const ray = best.sensor.rays[i]; + const reading = best.sensor.readings[i]; + const end = reading ? reading : ray[1]; + ctx.beginPath(); + ctx.lineWidth = 2; + ctx.strokeStyle = 'yellow'; + ctx.moveTo(ray[0].x, ray[0].y); + ctx.lineTo(end.x, end.y); + ctx.stroke(); + ctx.beginPath(); + ctx.strokeStyle = 'black'; + ctx.moveTo(ray[1].x, ray[1].y); + ctx.lineTo(end.x, end.y); + ctx.stroke(); + } + } + } + + // --------------------------------------------------------------- scene API + /** + * Called once per rAF from main.animate during phase 4. + * Returns { camApplied, drewRoad, drewSwarm } so main can skip default work. + */ + function beginFrame(ctx, best, snap) { + init(); + updateCamera(best); + + // Clear / road + if (state.view3d) { + // Always paint the projected track so pause / pre-snapshot frames + // aren't a blank canvas. drawSwarm will repaint on top when cars land. + ctx.setTransform(1, 0, 0, 1, 0, 0); + drawProjectedRoad(ctx); + drawHeatmap(ctx); + drawTrail(ctx); + return { camApplied: false, drewRoad: true, usePresentationSwarm: true }; + } + + const camApplied = beginCamera(ctx); + if (useRoadCache()) { + blitRoad(ctx); + return { camApplied, drewRoad: true, usePresentationSwarm: true }; + } + // No cache: caller should road.draw, but we'll still own swarm. + return { camApplied, drewRoad: false, usePresentationSwarm: true }; + } + + function endFrame(ctx, camApplied) { + endCamera(ctx, camApplied); + } + + // --------------------------------------------------------------- HUD + function setStory(text, ms) { + state.story = text || ''; + state.storyUntil = performance.now() + (ms || 4000); + } + + function tickHud(info) { + init(); + if (!state.hudEl) return; + const gen = info.generation | 0; + const snap = info.snap; + const best = info.bestCar; + let alive = '—', n = '—'; + if (snap && snap.N) { + n = snap.N; + let a = 0; + const pos = snap.positions; + for (let i = 0; i < snap.N; i++) if (!pos[i * 5 + 3]) a++; + alive = a; + if (gen !== state.lastGen) { + state.genStartAlive = a; + state.lastGen = gen; + clearTrail(); + } + } + let fitStr = '—'; + let fit = 0; + if (best && snap && snap.bestIdx >= 0 && snap.positions) { + fit = snap.positions[snap.bestIdx * 5 + 4] || 0; + } else if (typeof info.fitness === 'number') { + fit = info.fitness; + } + if (fit > 0) { + fitStr = fit.toFixed(2); + if (fit > state.peakFitness + 0.05) { + state.peakFitness = fit; + if (gen > 0) setStory('New best fitness: ' + fitStr, 2500); + } + state.lastFitness = fit; + } + const laps = best && best.laps ? best.laps : 0; + const speed = (typeof info.simSpeed === 'number') ? info.simSpeed : 1; + + const set = (key, html) => { + const el = state.hudEl.querySelector('[data-c="' + key + '"]'); + if (el) el.innerHTML = html; + }; + set('gen', 'Gen ' + gen + ''); + set('alive', 'Alive ' + alive + '/' + n + ''); + set('fit', 'Best ' + fitStr + ''); + set('laps', 'Laps ' + laps + ''); + set('speed', 'Sim ' + speed + '×'); + + // Story line + let story = ''; + if (performance.now() < state.storyUntil) story = state.story; + else if (info.pause) story = 'Paused — press ▶ Start Training or space-friendly Pause button.'; + else if (gen === 0 && alive !== '—' && typeof alive === 'number' && alive < (snap.N * 0.3)) { + story = 'Gen 0 chaos: most random brains crash. Survivors become parents.'; + } else if (gen >= 1 && gen < 4) { + story = 'Breeding winners… watch the pack hug the corridor.'; + } else if (laps >= 1) { + story = 'Champion completed a lap — the swarm is learning this track.'; + } else if (state.followBest) { + story = 'Following champion · sensors are the yellow rays.'; + } else if (state.view3d) { + story = '3D view of the same 2D sim · F follow · 0 reset camera.'; + } + if (state.storyEl) { + state.storyEl.textContent = story || ''; + state.storyEl.hidden = !story; + } + + // Demo sequencer beats + if (state.demoMode) tickDemo(info); + } + + function onGenEnd(genData) { + const gen = (typeof generation === 'number') ? generation : 0; + const fit = genData && typeof genData.fitness === 'number' ? genData.fitness : 0; + const alive = genData && genData.popStillAlive != null ? genData.popStillAlive : null; + const N = genData && genData.popN != null ? genData.popN : null; + let msg = 'Generation ' + gen + ' complete'; + if (fit) msg += ' · best ' + fit.toFixed(2); + if (alive != null && N) msg += ' · ' + alive + '/' + N + ' survived'; + setStory(msg, 3500); + // Soft-clear heatmap a bit at gen boundaries so it tracks current policy. + if (state.heat) { + for (let i = 0; i < state.heat.length; i++) state.heat[i] *= 0.55; + } + clearTrail(); + } + + // --------------------------------------------------------------- demo mode + function startDemoMode() { + state.demoMode = true; + state.demoStep = 0; + state.demoStarted = false; + setFollow(true); + state.trails = true; + state.heatmap = true; + setStory('Demo mode — starting training with a readable swarm.', 4000); + + // Align demo with product defaults (survival + ruvector-friendly), then + // bump speed a bit so the swarm story is visible without thrashing fitness. + try { + if (typeof applyTrainingPreset === 'function') { + applyTrainingPreset('fresh'); + } + if (typeof setSimSpeed === 'function') setSimSpeed(5); + if (typeof setN === 'function') setN(600); + const bs = document.getElementById('batchSizeInput'); + if (bs) { + bs.value = 600; + const o = document.getElementById('batchSizeOutput'); + if (o) o.value = 'Batch Size: 600'; + } + const ss = document.getElementById('simSpeedInput'); + if (ss) ss.value = '5'; + } catch (_) {} + + // Unpause if waiting on first start. + try { + if (typeof pause !== 'undefined' && pause && typeof pauseGame === 'function') { + pauseGame(); + } + if (typeof begin === 'function') begin(); + } catch (_) {} + + state.demoStarted = true; + syncControlButtons(); + } + + function tickDemo(info) { + if (!state.demoMode || !state.demoStarted) return; + const gen = info.generation | 0; + const best = info.bestCar; + // Step machine by generation milestones. + if (state.demoStep === 0 && gen >= 0) { + state.demoStep = 1; + setStory('Random brains flail. Yellow car is the current champion.', 5000); + } + if (state.demoStep === 1 && gen >= 2) { + state.demoStep = 2; + setStory('After a few gens the pack tightens — fitness-colored leaders pull ahead.', 5000); + } + if (state.demoStep === 2 && best && best.laps >= 1) { + state.demoStep = 3; + setStory('First lap! Brain is archived for warm-start on this or similar tracks.', 6000); + } + if (state.demoStep === 3 && gen >= 8) { + state.demoStep = 4; + // Optional: briefly show 3D for spectacle then leave user control. + if (!state.view3d) { + setView3d(true); + syncControlButtons(); + setStory('3D perspective — same physics, new view. Press 3 to toggle.', 5000); + setTimeout(() => { + // Don't force-off; user may like it. Just advance step. + state.demoStep = 5; + }, 8000); + } else { + state.demoStep = 5; + } + } + } + + // --------------------------------------------------------------- public + global.DemoPresentation = { + init, + invalidateRoad, + useRoadCache, + blitRoad, + beginFrame, + endFrame, + drawSwarm, + drawChampion, + tickHud, + onGenEnd, + setFollow, + setView3d, + resetCamera, + startDemoMode, + setStory, + get state() { return state; }, + }; + + // Boot on DOM ready — main.js may already be running. + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + // Defer one tick so canvas/globals exist. + setTimeout(init, 0); + } +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/AI-Car-Racer/index.html b/AI-Car-Racer/index.html index 4517c28..00f87dd 100644 --- a/AI-Car-Racer/index.html +++ b/AI-Car-Racer/index.html @@ -114,6 +114,9 @@

Train Your Model

+ + + diff --git a/AI-Car-Racer/main.js b/AI-Car-Racer/main.js index a102774..834d9dd 100644 --- a/AI-Car-Racer/main.js +++ b/AI-Car-Racer/main.js @@ -68,16 +68,23 @@ computeStartInfoInPlace(currentCheckpointList()); // Applied to AI cars only (not elite at i=0, not player cars). window.__poseJitter = window.__poseJitter || { radiusPx: 0, angleDeg: 0, maxAttempts: 8 }; -var batchSize = 10; -var nextSeconds = 15; -var seconds; -var mutateValue = .3; +// Default training knobs — tuned for visible ruvector warm-start + higher +// early survival (not micro-bench cold starts). Presets (Fresh/Grind/Polish) +// still override these when clicked. +// N=500 — enough lottery tickets for gen-0 survivors without thrashing +// 20s gens — less noisy fitness for archive quality +// mutate 0.22 — explore without shredding a decent elite every gen +// consInit 0.65 — bias random brains toward "ease off near walls" +var batchSize = 500; +var nextSeconds = 20; +var seconds = 20; +var mutateValue = 0.22; // P2.C Conservative Init: biases gen-0 random brains toward // "reverse when a ray reads short" (close wall). 0 = pure random (no-op, // bit-identical to pre-P2.C baseline). 1 = maximum bias. Persisted to // localStorage via setConservativeInit(). Only applied on cold-random-init // paths (fillRandom callers); ruvector-seeded brains are left untouched. -var conservativeInit = 0; +var conservativeInit = 0.65; var playerCar; var playerCar2; // AI population lives entirely inside sim-worker.js. bestCar on main is a @@ -232,7 +239,9 @@ try { localStorage.removeItem('fastLap'); } catch (_) {} // parallel accumulator for the 2 player cars only. They drift slightly under // load, but the UX impact is nil — AI training is what the user watches at // 100×; player cars are only driven during phase-3 physics tuning at 1×. -var simSpeed = 1; +// Default 2×: last "honest" speed before sensor stride > 1 (cleaner fitness +// for the archive + still visibly faster than realtime). +var simSpeed = 2; var _simStepAccum = 0; // retained name so setSimSpeed stays stable var _lastTickWall = performance.now(); @@ -896,6 +905,49 @@ function handleGenEnd(m){ bestCar.lapTimes = m.lapTimes && m.lapTimes.length ? m.lapTimes : '--'; bestCar.checkPointsCount = m.checkPointsCount; } + try { + if (window.DemoPresentation && typeof window.DemoPresentation.onGenEnd === 'function'){ + window.DemoPresentation.onGenEnd(m); + } + } catch (_) {} + // Adaptive green gates (opt-in): nudge bottleneck CPs before the next + // begin() so the worker reinits with the updated layout. Must run before + // performNextBatch → begin. + try { + if (window.AdaptiveGates && typeof window.AdaptiveGates.onGenEnd === 'function'){ + window.AdaptiveGates.onGenEnd(m); + } + } catch (e) { console.warn('[adaptiveGates] hook failed', e); } + // Always feed the crash-map HNSW when adaptive is off too, so the index + // accumulates "similar crash problem" memories for later retrieval. + try { + if ((!window.AdaptiveGates || !window.AdaptiveGates.isEnabled || !window.AdaptiveGates.isEnabled()) && + !window.rvDisabled && window.__rvBridge && + typeof window.__rvBridge.encodeCrashMap === 'function' && + typeof window.__rvBridge.archiveCrashMap === 'function' && + m.popDeathXY && m.popN) { + const vec = window.__rvBridge.encodeCrashMap(m.popDeathXY, m.popN); + if (vec) { + const cps = (road && road.checkPointList) ? road.checkPointList : null; + let nDeaths = 0; + for (let i = 0; i < m.popN; i++) { + if (Number.isFinite(m.popDeathXY[i * 2])) nDeaths++; + } + const geoSig = (window.AdaptiveGates && window.AdaptiveGates.geometrySignature) + ? window.AdaptiveGates.geometrySignature() + : null; + window.__rvBridge.archiveCrashMap(vec, { + survival: m.popN ? (m.popStillAlive | 0) / m.popN : 0, + fitness: m.fitness || 0, + generation: generation | 0, + nDeaths: nDeaths, + nGates: cps ? cps.length : 0, + cps: cps, + geometrySig: geoSig, + }); + } + } + } catch (e) { console.warn('[crash-map] passive archive failed', e); } // Feed one SONA trajectory step per generation — elite's last-tick hidden // activations as the embedding, generation fitness as the reward scalar. // Lazy-open the trajectory on the first genEnd after SONA becomes ready, @@ -1193,6 +1245,13 @@ function performBegin(N){ workerInited = true; } const brains = buildBrainsBuffer(N); + // Keep worker speed/pause aligned with main defaults. setSimSpeed() only + // posts when the user moves the dropdown — without this, a default + // simSpeed≠1 never reaches the worker until the first manual change. + try { + simWorker.postMessage({ type: 'setSimSpeed', v: simSpeed }); + simWorker.postMessage({ type: 'setPause', pause: !!pause }); + } catch (_) {} simWorker.postMessage({ type: 'begin', N, seconds, maxSpeed, traction, @@ -1236,6 +1295,18 @@ window.__switchTrackInMemory = function(name){ try { road.getTrack(); } catch (_) {} computeStartInfoInPlace(currentCheckpointList()); invalidateWorkerInit(); + try { + if (window.DemoPresentation && window.DemoPresentation.invalidateRoad){ + window.DemoPresentation.invalidateRoad(); + } + } catch (_) {} + // loadTrackPreset already calls AdaptiveGates.onTrackChange; call again + // after getTrack() so baseline matches fully rebuilt checkpoints. + try { + if (window.AdaptiveGates && typeof window.AdaptiveGates.onTrackChange === 'function') { + window.AdaptiveGates.onTrackChange(); + } + } catch (_) {} // Re-embed track vector for the new track so SONA patterns key correctly. try { if (typeof embedCurrentTrack === 'function') embedCurrentTrack(); } catch (_) {} return true; @@ -1358,7 +1429,16 @@ function animate(){ } var _perfDraw = 0; var _perfT0 = perfEnabled ? performance.now() : 0; - road.draw(ctx); + const DP = window.DemoPresentation; + // Presentation layer (road cache / follow-cam / 3D) owns the phase-4 + // frame setup. Outside training, fall back to the classic full redraw. + let _pres = null; + if (phase === 4 && DP && typeof DP.beginFrame === 'function'){ + _pres = DP.beginFrame(ctx, bestCar, latestSnapshot); + } + if (!_pres || !_pres.drewRoad){ + road.draw(ctx); + } if (perfEnabled) _perfDraw += performance.now() - _perfT0; if(phase==3){ @@ -1375,31 +1455,35 @@ function animate(){ } if(phase==4){ const timer = document.getElementById("timer"); - const simSecs = (frameCount/60).toFixed(2); - const wallSecs = ((performance.now() - wallStart)/1000).toFixed(2); - timer.innerHTML = "

Sim Time: " + simSecs + "s " + - "(wall " + wallSecs + "s · " + simSpeed + "×)

"; - // Phase A: track-aware fast-lap render. Three lines: - // • Fast Lap: 12.34s (this track) - // • Last: 14.10s - // • all-time best: 9.10s - // The "(this track)" tag is load-bearing — without it a returning - // user could assume the displayed value is global. allTimeBest is - // hidden when only one track has ever been raced (best === current). - const _fastStr = (typeof fastLap === 'number' ? fastLap.toFixed(2) + 's' : fastLap); - const _lastStr = (typeof lastLap === 'number' ? lastLap.toFixed(2) + 's' : '—'); - timer.innerHTML += "

Fast Lap: " + _fastStr + - " (this track)

"; - timer.innerHTML += "

Last: " + _lastStr + "

"; - if (typeof allTimeBest === 'number' && - (typeof fastLap !== 'number' || allTimeBest < fastLap)) { - // Only show the subscript when a different track holds the - // record — saves a row of noise for single-track users. - timer.innerHTML += "

" + - "all-time best: " + allTimeBest.toFixed(2) + "s

"; + if (timer){ + const simSecs = (frameCount/60).toFixed(2); + const wallSecs = ((performance.now() - wallStart)/1000).toFixed(2); + timer.innerHTML = "

Sim Time: " + simSecs + "s " + + "(wall " + wallSecs + "s · " + simSpeed + "×)

"; + // Phase A: track-aware fast-lap render. Three lines: + // • Fast Lap: 12.34s (this track) + // • Last: 14.10s + // • all-time best: 9.10s + // The "(this track)" tag is load-bearing — without it a returning + // user could assume the displayed value is global. allTimeBest is + // hidden when only one track has ever been raced (best === current). + const _fastStr = (typeof fastLap === 'number' ? fastLap.toFixed(2) + 's' : fastLap); + const _lastStr = (typeof lastLap === 'number' ? lastLap.toFixed(2) + 's' : '—'); + timer.innerHTML += "

Fast Lap: " + _fastStr + + " (this track)

"; + timer.innerHTML += "

Last: " + _lastStr + "

"; + if (typeof allTimeBest === 'number' && + (typeof fastLap !== 'number' || allTimeBest < fastLap)) { + // Only show the subscript when a different track holds the + // record — saves a row of noise for single-track users. + timer.innerHTML += "

" + + "all-time best: " + allTimeBest.toFixed(2) + "s

"; + } } - ctx.save(); + // Still render the last snapshot while paused so the track isn't empty + // after the user hits Pause / before first Start. + const shouldDrawCars = !pause || !!latestSnapshot; if(!pause){ // Local player-car accumulator. Runs in parallel with the worker's; // exact lockstep isn't needed because player cars only matter when @@ -1417,10 +1501,14 @@ function animate(){ playerCar.update(road.borders, road.checkPointList); playerCar2.update(road.borders, road.checkPointList); } + } + if (shouldDrawCars){ const _perfDrawT0 = perfEnabled ? performance.now() : 0; + const usePresSwarm = _pres && _pres.usePresentationSwarm && DP && typeof DP.drawSwarm === 'function'; if (latestSnapshot){ - drawFromSnapshot(latestSnapshot); + if (usePresSwarm) DP.drawSwarm(ctx, latestSnapshot); + else drawFromSnapshot(latestSnapshot); } if (bestCar){ // Pass the whole bestCar proxy — inputVisual still reads @@ -1428,13 +1516,31 @@ function animate(){ // reads .brainInputs / .brainOutputActivations (Task 2.D) to // render the NN decision bars. inputVisual(bestCar); - drawBestCar(bestCar); + if (DP && typeof DP.drawChampion === 'function') DP.drawChampion(ctx, bestCar); + else drawBestCar(bestCar); + } + // Player cars are 2D-world quads — skip in pure 3D projection so + // they don't ghost in flat screen space over the perspective scene. + const skipPlayers = DP && DP.state && DP.state.view3d; + if (!skipPlayers){ + if (playerCar) playerCar.draw(ctx,"#E6194B",true); + if (playerCar2) playerCar2.draw(ctx,"#4FC3F7",true); } - playerCar.draw(ctx,"#E6194B",true); - playerCar2.draw(ctx,"#4FC3F7",true); if (perfEnabled) _perfDraw += performance.now() - _perfDrawT0; } - ctx.restore(); + if (_pres && DP && typeof DP.endFrame === 'function'){ + DP.endFrame(ctx, _pres.camApplied); + } + if (DP && typeof DP.tickHud === 'function'){ + DP.tickHud({ + generation: generation, + bestCar: bestCar, + snap: latestSnapshot, + pause: pause, + simSpeed: simSpeed, + frameCount: frameCount, + }); + } } if (perfEnabled){ try { diff --git a/AI-Car-Racer/roadEditor.js b/AI-Car-Racer/roadEditor.js index 7e407c5..0886670 100644 --- a/AI-Car-Racer/roadEditor.js +++ b/AI-Car-Racer/roadEditor.js @@ -91,14 +91,38 @@ class roadEditor{ // this.ctx=document.getElementById("myCanvas").getContext("2d"); if (this.points.length > 0) { this.ctx.clearRect(0, 0, canvas.width, canvas.height); - if(this.editMode){ - this.drawCircles(); + this.paintTo(this.ctx); + } else { + this.drawStartPos(this.startInfo, this.ctx); + } + // Track edits invalidate the presentation road cache (if present). + try { + if (window.DemoPresentation && window.DemoPresentation.invalidateRoad) { + window.DemoPresentation.invalidateRoad(); } - this.drawLines(); + } catch (_) {} + } + + // Paint walls / checkpoints / start flag to any 2d context. Used by the + // live redraw path and by DemoPresentation's offscreen road cache so + // training frames can blit a static road instead of re-stroking geometry. + paintTo(c) { + if (!c) c = this.ctx; + if (this.editMode) { + // drawCircles uses this.ctx — temporarily rebind. + const prev = this.ctx; + this.ctx = c; + try { this.drawCircles(); } finally { this.ctx = prev; } } - this.drawStartPos(this.startInfo, this.ctx); + this.drawLinesOn(c); + this.drawStartPos(this.startInfo, c); } + drawLines() { + this.drawLinesOn(this.ctx); + } + + drawLinesOn(c) { // Canvas is 3200x1800 but the layout downscales it ~3x in CSS pixels, // so sub-3px strokes vanish after downsample. Use 10–12 canvas px // during gameplay (editMode=false) so walls remain legible to the eye. @@ -106,46 +130,46 @@ class roadEditor{ const closeW = this.editMode ? .75 : 12; const checkW = this.editMode ? 2 : 8; - this.ctx.beginPath(); - this.ctx.moveTo(this.points[0].x, this.points[0].y); - this.ctx.strokeStyle = "white"; - this.ctx.lineWidth = wallW; + c.beginPath(); + c.moveTo(this.points[0].x, this.points[0].y); + c.strokeStyle = "white"; + c.lineWidth = wallW; this.points.forEach((p) => { - this.ctx.lineTo(p.x, p.y); + c.lineTo(p.x, p.y); }) - this.ctx.stroke(); - this.ctx.lineWidth = closeW; - this.ctx.globalAlpha = this.editMode?.2:1; - this.ctx.lineTo(this.points[0].x,this.points[0].y); - this.ctx.stroke(); - this.ctx.globalAlpha = 1; + c.stroke(); + c.lineWidth = closeW; + c.globalAlpha = this.editMode?.2:1; + c.lineTo(this.points[0].x,this.points[0].y); + c.stroke(); + c.globalAlpha = 1; - this.ctx.beginPath(); - this.ctx.moveTo(this.points2[0].x, this.points2[0].y); - this.ctx.strokeStyle = "white"; - this.ctx.lineWidth = wallW; + c.beginPath(); + c.moveTo(this.points2[0].x, this.points2[0].y); + c.strokeStyle = "white"; + c.lineWidth = wallW; this.points2.forEach((p) => { - this.ctx.lineTo(p.x, p.y); + c.lineTo(p.x, p.y); }) - this.ctx.stroke(); - this.ctx.lineWidth = closeW; - this.ctx.globalAlpha = this.editMode?.2:1; - this.ctx.lineTo(this.points2[0].x,this.points2[0].y); - this.ctx.stroke(); - this.ctx.globalAlpha = 1; + c.stroke(); + c.lineWidth = closeW; + c.globalAlpha = this.editMode?.2:1; + c.lineTo(this.points2[0].x,this.points2[0].y); + c.stroke(); + c.globalAlpha = 1; // Checkpoints: named-"green" (#008000) reads only 3.2:1 on the // near-black scene (#15161a) and collapses toward muddy olive under // deuteranopia. #58E05D lime jumps to ~7:1, stays clearly green to // trichromats, and stays distinct from the yellow sensor rays by // luminance even under red-green CVD. - this.ctx.strokeStyle = "#58E05D"; - this.ctx.lineWidth = checkW; + c.strokeStyle = "#58E05D"; + c.lineWidth = checkW; this.checkPointListEditor.forEach((p)=>{ - this.ctx.beginPath(); - this.ctx.moveTo(p[0].x,p[0].y); - this.ctx.lineTo(p[1].x,p[1].y); - this.ctx.stroke(); + c.beginPath(); + c.moveTo(p[0].x,p[0].y); + c.lineTo(p[1].x,p[1].y); + c.stroke(); }) // 1-based index labels: the order (cp[0]→cp[1]→…) determines lap @@ -155,10 +179,10 @@ class roadEditor{ // Labels are tangent-offset so they sit just off the gate line rather // than clipping on top of it. const labelPx = this.editMode ? 40 : 60; - this.ctx.font = `bold ${labelPx}px Tahoma, sans-serif`; - this.ctx.textAlign = 'center'; - this.ctx.textBaseline = 'middle'; - this.ctx.lineWidth = 4; + c.font = `bold ${labelPx}px Tahoma, sans-serif`; + c.textAlign = 'center'; + c.textBaseline = 'middle'; + c.lineWidth = 4; this.checkPointListEditor.forEach((p, i) => { const mx = (p[0].x + p[1].x) / 2; const my = (p[0].y + p[1].y) / 2; @@ -172,10 +196,10 @@ class roadEditor{ const lx = mx + nx * off; const ly = my + ny * off; const label = String(i + 1); - this.ctx.strokeStyle = "#15161a"; // dark outline matches scene bg - this.ctx.strokeText(label, lx, ly); - this.ctx.fillStyle = "#58E05D"; - this.ctx.fillText(label, lx, ly); + c.strokeStyle = "#15161a"; // dark outline matches scene bg + c.strokeText(label, lx, ly); + c.fillStyle = "#58E05D"; + c.fillText(label, lx, ly); }); } deleteLast(){ diff --git a/AI-Car-Racer/ruvectorBridge.js b/AI-Car-Racer/ruvectorBridge.js index d5aeb2c..0e35ddf 100644 --- a/AI-Car-Racer/ruvectorBridge.js +++ b/AI-Car-Racer/ruvectorBridge.js @@ -107,16 +107,24 @@ const IDB_NAME = 'rv_car_learning'; // so old archives continue to hydrate unchanged — they just don't have // dynamicsId set on any brain meta (backwards-compat: missing → skip the // dynamics term in recommendSeeds). -const IDB_VERSION = 3; +// Bumped to 4 for crash-map HNSW store (adaptive-gates curriculum memory). +const IDB_VERSION = 4; const BRAINS_STORE = `brains_${TOPOLOGY.join('_')}`; // topology-scoped per PRD risk #6 const TRACKS_STORE = 'tracks'; const OBS_STORE = 'observations'; const LORA_STORE = 'lora_track'; const LORA_KEY = 'singleton'; // single-row store; this is the only id ever used const DYNAMICS_STORE = 'dynamics'; +const CRASH_STORE = 'crash_maps'; const TRACK_DIM = 512; const DYNAMICS_DIM = 64; // matches dynamicsEmbedder.DYNAMICS_DIM +// Crash heat maps: 16×9 log1p-count grid over the canvas, L2-normalised. +// Same layout as crashMapCodec.js (classic) — keep in sync. +const CRASH_GW = 16; +const CRASH_GH = 9; +const CRASH_DIM = CRASH_GW * CRASH_GH; // 144 +export { CRASH_DIM, CRASH_GW, CRASH_GH }; // VectorDB returns cosine DISTANCE (1 - similarity), range [0, 2]. Dedup when // distance is tiny, i.e. the two track vectors are essentially identical. const TRACK_DEDUPE_MAX_DIST = 0.005; // ≈ 0.9975 cosine similarity @@ -221,7 +229,15 @@ let _brainDB = null; let _brainDB_hyperbolic = null; let _trackDB = null; let _dynamicsDB = null; +let _crashDB = null; +let _crashMirror = new Map(); // id -> { vector, meta } let _cnn = null; +// Crash-map retrieval toggle (default on). Adaptive gates can still call +// archive/search when disabled for diagnostics; recommendCrashLayouts returns +// [] when off so the curriculum path no-ops cleanly. +let _useCrashMaps = true; +export function setUseCrashMaps(on) { _useCrashMaps = !!on; return _useCrashMaps; } +export function isUsingCrashMaps() { return !!_useCrashMaps; } // Phase 2A — F2. Off by default → recommendSeeds is byte-identical to the // pre-2A single-index path. Flipped via setFederationEnabled({on}). @@ -282,11 +298,12 @@ export function setIndexKind(kind) { return true; } function rebuildIndicesFromMirror() { - if (!_brainDB || !_trackDB || !_dynamicsDB) return; + if (!_brainDB || !_trackDB || !_dynamicsDB || !_crashDB) return; const IndexClass = pickIndexClass(_indexKind); _brainDB = new IndexClass(FLAT_LENGTH, 'cosine'); _trackDB = new IndexClass(TRACK_DIM, 'cosine'); _dynamicsDB = new IndexClass(DYNAMICS_DIM, 'cosine'); + _crashDB = new IndexClass(CRASH_DIM, 'cosine'); // Phase 2A — rebuild the shadow hyperbolic brain index too when // available. We don't shadow the track / dynamics DBs — federation only // fans out over the brain index (track / dynamics are joins, not @@ -302,6 +319,9 @@ function rebuildIndicesFromMirror() { for (const [id, { vector, meta }] of _dynamicsMirror) { _dynamicsDB.insert(vector, id, meta || {}); } + for (const [id, { vector, meta }] of _crashMirror) { + _crashDB.insert(vector, id, meta || {}); + } // F3 — rebuild preserves the original insertion order from _insertionOrder // when available; falls back to mirror iteration order otherwise. We do // NOT reset _insertionOrder here since the mirror contents are unchanged. @@ -319,11 +339,12 @@ function rebuildIndicesFromMirror() { } } -// Dynamics retrieval toggle. Default off per P1.C plan: adding the extra -// similarity term shifts seeding behaviour, so we keep it opt-in and let the -// panel checkbox drive it. `_queryDynamicsVec` is set by callers (main.js / -// uiPanels) before recommendSeeds so the bridge stays pure-read. -let _useDynamics = false; +// Dynamics retrieval toggle. Default ON for product UX: once trajectories +// exist, seeding can prefer brains that *drive like* successful ones on this +// track (not only track-shape neighbors). Empty dynamics archive is a no-op +// (term skipped). UI checkbox / A/B strip stay authoritative after first paint. +// `_queryDynamicsVec` is set by callers (main.js / uiPanels) before recommendSeeds. +let _useDynamics = true; let _queryDynamicsVec = null; // Weight of the dynamics-sim term in the final score product. Small enough // that dynamics can tie-break but won't drown out fitness × track-similarity. @@ -417,6 +438,7 @@ export function ready() { _brainDB = new IndexClass(FLAT_LENGTH, 'cosine'); _trackDB = new IndexClass(TRACK_DIM, 'cosine'); _dynamicsDB = new IndexClass(DYNAMICS_DIM, 'cosine'); + _crashDB = new IndexClass(CRASH_DIM, 'cosine'); // Phase 2A — F2. Stand up the hyperbolic shadow brain index so federated // search has something to fan out to, regardless of whether _indexKind is // currently hyperbolic. When the wasm didn't load this stays null and @@ -475,8 +497,9 @@ export function ready() { tracks: _trackMirror.size, obs: _observations.size, dynamics: _dynamicsMirror.size, + crashMaps: _crashMirror.size, }; - console.log(`[ruvector] ready — brains=${_brainMirror.size} tracks=${_trackMirror.size} obs=${_observations.size}`); + console.log(`[ruvector] ready — brains=${_brainMirror.size} tracks=${_trackMirror.size} obs=${_observations.size} crashMaps=${_crashMirror.size}`); // One compact line so the full breakdown survives console truncation and // can be copy-pasted back for diagnosis. console.log('[boot-timings] ' + JSON.stringify(_bootTimings)); @@ -485,11 +508,141 @@ export function ready() { } function requireReady() { - if (!_brainDB || !_trackDB || !_dynamicsDB || !_cnn) { + if (!_brainDB || !_trackDB || !_dynamicsDB || !_crashDB || !_cnn) { throw new Error('ruvectorBridge: call await ready() before using the bridge'); } } +// ─── crash-map HNSW (adaptive-gates curriculum memory) ─────────────────────── + +/** + * Encode death positions into a CRASH_DIM L2-normalised heat vector. + * Mirrors window.CrashMapCodec.encodeDeathMap when that classic script loaded. + */ +export function encodeCrashMap(popDeathXY, N, canvasW, canvasH) { + try { + if (typeof window !== 'undefined' && window.CrashMapCodec && + typeof window.CrashMapCodec.encodeDeathMap === 'function') { + return window.CrashMapCodec.encodeDeathMap(popDeathXY, N, canvasW, canvasH); + } + } catch (_) {} + // Inline fallback (same algorithm as crashMapCodec.js) + if (!popDeathXY || !N) return null; + const W = canvasW || 3200, H = canvasH || 1800; + const grid = new Float32Array(CRASH_DIM); + let deaths = 0; + for (let i = 0; i < N; i++) { + const x = popDeathXY[i * 2], y = popDeathXY[i * 2 + 1]; + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + let gx = Math.floor((x / W) * CRASH_GW); + let gy = Math.floor((y / H) * CRASH_GH); + if (gx < 0) gx = 0; else if (gx >= CRASH_GW) gx = CRASH_GW - 1; + if (gy < 0) gy = 0; else if (gy >= CRASH_GH) gy = CRASH_GH - 1; + grid[gy * CRASH_GW + gx] += 1; + deaths++; + } + if (deaths < 3) return null; + let sumSq = 0; + for (let i = 0; i < CRASH_DIM; i++) { + const v = Math.log1p(grid[i]); + grid[i] = v; + sumSq += v * v; + } + const norm = Math.sqrt(sumSq); + if (norm < 1e-9) return null; + const inv = 1 / norm; + for (let i = 0; i < CRASH_DIM; i++) grid[i] *= inv; + return grid; +} + +/** + * Archive a crash heat map + optional gate layout / survival meta. + * @returns {string|null} id + */ +export function archiveCrashMap(crashVec, meta = {}) { + if (!_crashDB) return null; + if (!(crashVec instanceof Float32Array) || crashVec.length !== CRASH_DIM) return null; + const m = { + survival: Number(meta.survival) || 0, + fitness: Number(meta.fitness) || 0, + generation: (meta.generation | 0), + nDeaths: (meta.nDeaths | 0), + nGates: (meta.nGates | 0), + timestamp: Date.now(), + }; + if (Array.isArray(meta.cps)) m.cps = meta.cps; + if (meta.causes && typeof meta.causes === 'object') m.causes = meta.causes; + if (meta.trackId) m.trackId = meta.trackId; + if (meta.bottleneck != null) m.bottleneck = meta.bottleneck | 0; + // Wall-geometry signature — adaptive gates refuse to apply a layout whose + // sig doesn't match the live track (stops Triangle gates on Rectangle). + if (meta.geometrySig) m.geometrySig = String(meta.geometrySig); + try { + const id = _crashDB.insert(crashVec, null, m); + _crashMirror.set(id, { vector: crashVec.slice(), meta: m }); + schedulePersist(); + return id; + } catch (e) { + console.warn('[crash-map] insert failed', e); + return null; + } +} + +/** + * Nearest crash maps in HNSW. Returns [] when disabled / empty / not ready. + * Score is cosine similarity in [0,1] (converted from VectorDB distance). + */ +export function recommendCrashLayouts(crashVec, k = 5) { + if (!_useCrashMaps || !_crashDB || _crashMirror.size === 0) return []; + if (!(crashVec instanceof Float32Array) || crashVec.length !== CRASH_DIM) return []; + const kk = Math.max(1, Math.min(k | 0, _crashMirror.size)); + let hits; + try { + hits = _crashDB.search(crashVec, kk); + } catch (e) { + console.warn('[crash-map] search failed', e); + return []; + } + if (!hits || !hits.length) return []; + const out = []; + for (let i = 0; i < hits.length; i++) { + const h = hits[i]; + const id = h.id; + const entry = _crashMirror.get(id); + // VectorDB cosine distance ∈ [0, 2]; sim = 1 - dist/2 roughly, or 1-dist for unit vectors. + // Our vectors are L2-normalised; cosine distance ≈ 1 - cos_sim for some builds. + // Prefer metadata from mirror; fall back to hit.metadata. + const meta = (entry && entry.meta) || h.metadata || {}; + // VectorDB cosine DISTANCE: 0 = identical, 2 = opposite (same as tracks). + const dist = Number(h.score); + const sim = Number.isFinite(dist) + ? Math.max(0, Math.min(1, 1 - dist / 2)) + : 0; + out.push({ + id, + similarity: sim, + distance: dist, + survival: Number(meta.survival) || 0, + fitness: Number(meta.fitness) || 0, + generation: meta.generation | 0, + nGates: meta.nGates | 0, + nDeaths: meta.nDeaths | 0, + cps: Array.isArray(meta.cps) ? meta.cps : null, + causes: meta.causes || null, + bottleneck: meta.bottleneck != null ? (meta.bottleneck | 0) : null, + geometrySig: meta.geometrySig || null, + timestamp: meta.timestamp || 0, + }); + } + // Prefer high similarity, then high survival + out.sort((a, b) => (b.similarity - a.similarity) || (b.survival - a.survival)); + return out; +} + +export function crashMapCount() { + return _crashMirror.size; +} + // P1.C — dynamics retrieval controls. UI owns the toggle; `setUseDynamics` // flips the flag, `setQueryDynamicsVec` stages the current-generation vector // the next recommendSeeds() call will use. Both are no-ops when the bridge @@ -1265,6 +1418,12 @@ export function info() { count: _dynamicsMirror.size, hasQuery: !!_queryDynamicsVec, }, + // Crash-map HNSW — heat-grid curriculum memory for adaptive gates. + crashMaps: { + enabled: !!_useCrashMaps, + count: _crashMirror.size, + dim: CRASH_DIM, + }, // P3.B — lineage DAG stats. `lineageDag.ready` is the canonical flag for // the viewer's "is the graph live?" check. `nodeCount` / `edgeCount` come // straight from the wasm side; `droppedEdges` is >0 only if malformed @@ -1413,6 +1572,8 @@ function openDB() { // dynamics vector, keyed by the _dynamicsDB-assigned id — mirrors the // brains/tracks store shape. if (!db.objectStoreNames.contains(DYNAMICS_STORE)) db.createObjectStore(DYNAMICS_STORE, { keyPath: 'id' }); + // CRASH_STORE added in IDB v4 — gen-end death heat maps + gate layouts. + if (!db.objectStoreNames.contains(CRASH_STORE)) db.createObjectStore(CRASH_STORE, { keyPath: 'id' }); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); @@ -1440,8 +1601,10 @@ export async function hydrate() { // readAll is safe. Still, wrap in try/catch to be defensive against // partial upgrades from a crashed earlier session. let dynamicsRows = []; + let crashRows = []; const _tIdb = _tStart(); try { dynamicsRows = await readAll(db, DYNAMICS_STORE); } catch (_) { dynamicsRows = []; } + try { crashRows = await readAll(db, CRASH_STORE); } catch (_) { crashRows = []; } const [brainRows, trackRows, obsRows] = await Promise.all([ readAll(db, BRAINS_STORE), readAll(db, TRACKS_STORE), @@ -1453,6 +1616,7 @@ export async function hydrate() { tracks: trackRows.length, obs: obsRows.length, dynamics: dynamicsRows.length, + crashMaps: crashRows.length, }; // Tracks first, so upsertTrack-dedup can reference them (not strictly needed // since we load by id, but keeps mirror/DB consistent). @@ -1476,6 +1640,16 @@ export async function hydrate() { _dynamicsMirror.set(row.id, { vector: vec, meta: row.meta || {} }); } _tEnd('3c_insert_dynamics', _tDyn); + const _tCrash = _tStart(); + for (const row of crashRows) { + const vec = toFloat32(row.vec); + if (vec.length !== CRASH_DIM) continue; + try { + _crashDB.insert(vec, row.id, row.meta || {}); + _crashMirror.set(row.id, { vector: vec, meta: row.meta || {} }); + } catch (e) { console.warn('[crash-map] hydrate insert failed', e); } + } + _tEnd('3c2_insert_crashMaps', _tCrash); const _tBrains = _tStart(); for (const row of brainRows) { const vec = toFloat32(row.vec); @@ -1571,12 +1745,15 @@ export async function persist() { _persistInFlight = (async () => { const db = await openDB(); try { - const tx = db.transaction([BRAINS_STORE, TRACKS_STORE, OBS_STORE, LORA_STORE, DYNAMICS_STORE], 'readwrite'); + const storeNames = [BRAINS_STORE, TRACKS_STORE, OBS_STORE, LORA_STORE, DYNAMICS_STORE]; + if (db.objectStoreNames.contains(CRASH_STORE)) storeNames.push(CRASH_STORE); + const tx = db.transaction(storeNames, 'readwrite'); const brains = tx.objectStore(BRAINS_STORE); const tracks = tx.objectStore(TRACKS_STORE); const obs = tx.objectStore(OBS_STORE); const lora = tx.objectStore(LORA_STORE); const dynamics = tx.objectStore(DYNAMICS_STORE); + const crashes = db.objectStoreNames.contains(CRASH_STORE) ? tx.objectStore(CRASH_STORE) : null; // Full rewrite keeps the logic simple; archive size stays small (hundreds // of entries, <100KB serialized) so the write cost is negligible. brains.clear(); @@ -1584,6 +1761,7 @@ export async function persist() { obs.clear(); lora.clear(); dynamics.clear(); + if (crashes) crashes.clear(); for (const [id, { vector, meta }] of _brainMirror) { brains.put({ id, vec: Array.from(vector), meta }); } @@ -1593,6 +1771,11 @@ export async function persist() { for (const [id, { vector, meta }] of _dynamicsMirror) { dynamics.put({ id, vec: Array.from(vector), meta }); } + if (crashes) { + for (const [id, { vector, meta }] of _crashMirror) { + crashes.put({ id, vec: Array.from(vector), meta }); + } + } for (const [id, { weight, count }] of _observations) { obs.put({ id, weight, count }); } @@ -1880,6 +2063,7 @@ export async function _debugReset() { _brainMirror.clear(); _trackMirror.clear(); _dynamicsMirror.clear(); + _crashMirror.clear(); _observations.clear(); _insertionOrder = []; _queryDynamicsVec = null; diff --git a/AI-Car-Racer/sim-worker.js b/AI-Car-Racer/sim-worker.js index 37cf4ce..8eeadc6 100644 --- a/AI-Car-Racer/sim-worker.js +++ b/AI-Car-Racer/sim-worker.js @@ -322,6 +322,9 @@ function stepOnce() { if (!prevDamaged && cc.damaged) { cc.deathFrame = self.frameCount; cc.slideAtDeath = prevSlide; + // Crash locus for adaptive-gates / heat analysis on main. + cc.deathX = cc.x; + cc.deathY = cc.y; } } // O(N) bestCar scan — mirror of main.js's pre-worker logic. @@ -504,6 +507,9 @@ function endGen() { // 2=slide-out, 3=stalled, 4=alive. Mutually exclusive — sum across buckets // equals N. Classification is O(N) at endGen (no per-frame cost). const popDeathCauses = new Int8Array(N); + // Crash positions (x,y per car). Alive / never-damaged → NaN so consumers + // can skip. Used by AdaptiveGates crash-heat curriculum on main. + const popDeathXY = new Float32Array(N * 2); let wallBumps = 0, stillAlive = 0; for (let i = 0; i < N; i++) { const c = cars[i]; @@ -511,6 +517,8 @@ function endGen() { if (c.damaged) { wallBumps++; popDeathFrames[i] = (c.deathFrame != null ? c.deathFrame : self.frameCount) | 0; + popDeathXY[i * 2] = (c.deathX != null ? c.deathX : c.x); + popDeathXY[i * 2 + 1] = (c.deathY != null ? c.deathY : c.y); // Forward speed magnitude — `c.speed` is scalar along the car's // heading axis; |speed|/maxSpeed > 0.7 means the car was driving // hard into the wall rather than scraping it laterally. @@ -530,6 +538,8 @@ function endGen() { stillAlive++; popDeathFrames[i] = -1; popDeathCauses[i] = (c.checkPointsCount | 0) >= 1 ? 4 : 3; + popDeathXY[i * 2] = NaN; + popDeathXY[i * 2 + 1] = NaN; } } @@ -543,7 +553,13 @@ function endGen() { if (lev0 && lev0.outputs) bestHiddenActivations = new Float32Array(lev0.outputs); } catch (_) {} - const transfer = [flat.buffer, popCheckpoints.buffer, popDeathFrames.buffer, popDeathCauses.buffer]; + const transfer = [ + flat.buffer, + popCheckpoints.buffer, + popDeathFrames.buffer, + popDeathCauses.buffer, + popDeathXY.buffer, + ]; if (bestHiddenActivations) transfer.push(bestHiddenActivations.buffer); self.postMessage({ @@ -557,7 +573,7 @@ function endGen() { popN: N, popWallBumps: wallBumps, popStillAlive: stillAlive, - popCheckpoints, popDeathFrames, popDeathCauses, + popCheckpoints, popDeathFrames, popDeathCauses, popDeathXY, bestHiddenActivations, genSeconds: seconds }, transfer); diff --git a/AI-Car-Racer/style.css b/AI-Car-Racer/style.css index 9e249a6..c9c432e 100644 --- a/AI-Car-Racer/style.css +++ b/AI-Car-Racer/style.css @@ -2217,6 +2217,18 @@ label { font-weight: 600; letter-spacing: 0.02em; } +.rv-experiments-status { + flex: 1 1 100%; + margin-top: 2px; + font: 11px/1.35 var(--font-mono); + color: rgba(168, 200, 255, 0.9); + opacity: 0.92; +} +.rv-experiments-reset-gates { + margin-left: auto; + padding: 2px 8px !important; + font-size: 0.78em !important; +} /* === Phase A — Brain saves (named-slot persistence) === */ /* Mounted directly below the legacy Save Best + Restart actions, so the @@ -2282,3 +2294,139 @@ label { color: #f0d6b6 !important; } .brain-saves-fresh:hover { background: rgba(180, 110, 60, 0.32) !important; } + +/* ============================================================= + Cinematic presentation layer (demoPresentation.js) + Overlay HUD + view toggles on #canvasDiv. Pointer-events none + on the HUD; controls are clickable chips. + ============================================================= */ +#cinema-hud { + position: absolute; + top: 10px; + left: 10px; + z-index: 8; + max-width: min(92%, 560px); + padding: 8px 12px; + border-radius: 12px; + background: rgba(12, 14, 18, 0.72); + -webkit-backdrop-filter: blur(10px) saturate(130%); + backdrop-filter: blur(10px) saturate(130%); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28); + color: #f3efe6; + font-family: var(--font-body); + font-size: 12px; + line-height: 1.35; + pointer-events: none; +} +.cinema-hud-row { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + align-items: baseline; +} +.cinema-stat { + color: rgba(243, 239, 230, 0.78); + letter-spacing: 0.01em; +} +.cinema-stat b { + color: #ffe566; + font-family: var(--font-mono); + font-weight: 500; + font-size: 12.5px; +} +.cinema-muted { + opacity: 0.55; + font-size: 0.92em; +} +.cinema-story { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + color: rgba(255, 236, 180, 0.92); + font-size: 12px; + font-family: var(--font-display); + font-weight: 500; + letter-spacing: -0.01em; +} +.cinema-story[hidden] { display: none; } + +#cinema-controls { + /* Bottom-right of the track cell so we don't collide with the + bottom-left perf HUD or the centered #bottomText banner. */ + position: absolute; + bottom: 12px; + right: 12px; + left: auto; + z-index: 8; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + max-width: min(70%, 480px); + pointer-events: auto; +} +#cinema-controls button { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(12, 14, 18, 0.72); + color: rgba(243, 239, 230, 0.88); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + border-radius: 999px; + padding: 5px 11px; + font: 600 11px/1.2 var(--font-body); + letter-spacing: 0.02em; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease; +} +#cinema-controls button:hover { + background: rgba(28, 30, 38, 0.88); + border-color: rgba(255, 229, 102, 0.35); + color: #fff; +} +#cinema-controls button:focus-visible { + outline: 2px solid var(--amber-500); + outline-offset: 2px; +} +#cinema-controls button.is-on { + background: rgba(211, 139, 75, 0.28); + border-color: rgba(255, 229, 102, 0.55); + color: #ffe566; +} +#cinema-controls button.cinema-demo.is-on { + background: rgba(122, 101, 204, 0.35); + border-color: rgba(190, 175, 255, 0.55); + color: #e4dcff; +} + +/* Keep overlays clear of the A/B dual stack */ +#canvasDiv.ab-on #cinema-hud { + max-width: min(48%, 360px); +} +#canvasDiv.ab-on #cinema-controls { + bottom: auto; + top: calc(50% + 8px); + right: 8px; +} + +@media (max-width: 720px) { + #cinema-hud { + font-size: 11px; + padding: 6px 9px; + top: 6px; + left: 6px; + } + #cinema-controls { + bottom: 8px; + right: 8px; + } + #cinema-controls button { + padding: 4px 9px; + font-size: 10px; + } +} + +@media (prefers-reduced-motion: reduce) { + #cinema-controls button { transition: none; } +} diff --git a/AI-Car-Racer/trackPresets.js b/AI-Car-Racer/trackPresets.js index 37a92db..f348ea1 100644 --- a/AI-Car-Racer/trackPresets.js +++ b/AI-Car-Racer/trackPresets.js @@ -98,25 +98,46 @@ window.TRACK_PRESETS = [ }, { name: 'Triangle', - description: 'Apex points left; spawn sits in the spacious right lobe.', - // Apex-left triangle. cp[0] sits low on the right edge and cp[1] sits - // high on the same edge (directly above), so the car spawns facing - // screen-up and drives up the right edge before cornering toward the apex. + description: 'Clean apex-left triangle, symmetric about y=900. Spacious right lobe; full-loop gates with apex approach/exit.', + // Apex-left isosceles triangle, mirror-symmetric about the horizontal + // midline (y=900). Blunted inner tip keeps the sharp left turn learnable; + // outer/inner bases share x so the right lobe is a clean rectangle. + // + // Corridor ~200–280px on the long edges, ~580px on the right lobe. + // Gates are outer→inner (or right-lobe horizontal) along the driving line: + // 1 spawn → 2 right-upper → 3 top-right mouth → 4 top-mid + // → 5 top near-apex → 6 apex → 7 bottom near-apex + // → 8 bottom-mid → 9 bottom-right mouth → (1 for lap) points: [ - { x: 500, y: 900 }, // left apex (inner) - { x: 2400, y: 500 }, // top-right - { x: 2400, y: 1300 } // bottom-right + { x: 780, y: 900 }, // left apex (inner, blunted) + { x: 2420, y: 480 }, // top-right + { x: 2420, y: 1320 } // bottom-right ], points2: [ - { x: 150, y: 900 }, // left apex (outer) - { x: 3100, y: 250 }, // top-right - { x: 3100, y: 1550 } // bottom-right + { x: 180, y: 900 }, // left apex (outer) + { x: 3000, y: 80 }, // top-right + { x: 3000, y: 1720 } // bottom-right ], checkPointListEditor: [ - [{ x: 3100, y: 1200 }, { x: 2400, y: 1200 }], // 1: right-low (spawn) - [{ x: 3100, y: 600 }, { x: 2400, y: 600 }], // 2: right-top (above cp[0] → heading up) - [{ x: 1500, y: 550 }, { x: 1500, y: 720 }], // 3: top edge - [{ x: 150, y: 900 }, { x: 500, y: 900 }] // 4: left apex + // 1: right-low spawn / start-finish — horizontal across the right lobe + [{ x: 3000, y: 1220 }, { x: 2420, y: 1220 }], + // 2: right-upper — directly above #1 so spawn heading = screen-up + [{ x: 3000, y: 700 }, { x: 2420, y: 700 }], + // 3: top-right mouth — angled outer→inner at the top base corner + // (shortened 22% from outer so it sits in the driving line) + [{ x: 2872, y: 168 }, { x: 2420, y: 480 }], + // 4: top-mid — outer/inner top edges @ t=0.42 from apex + [{ x: 1364, y: 556 }, { x: 1469, y: 724 }], + // 5: top near-apex approach (hand-tuned — short, tight to tip) + [{ x: 695, y: 707 }, { x: 778, y: 898 }], + // 6: apex — from mid-corridor into the inner tip + [{ x: 461, y: 902 }, { x: 780, y: 900 }], + // 7: bottom near-apex exit (hand-tuned mirror of #5) + [{ x: 674, y: 1071 }, { x: 778, y: 909 }], + // 8: bottom-mid — mirror of #4 + [{ x: 1364, y: 1244 }, { x: 1469, y: 1076 }], + // 9: bottom-right mouth — mirror of #3 + [{ x: 2872, y: 1632 }, { x: 2420, y: 1320 }] ] }, { @@ -520,6 +541,11 @@ window.loadTrackPreset = function(nameOrIdx) { localStorage.removeItem('progress'); localStorage.removeItem('rvAnnotations'); try { if (typeof resetTrainCount === 'function') resetTrainCount(); } catch (_) {} + try { + if (window.DemoPresentation && window.DemoPresentation.invalidateRoad) { + window.DemoPresentation.invalidateRoad(); + } + } catch (_) {} // Swap the in-memory editor state if the game has booted. if (typeof road !== 'undefined' && road.roadEditor) { @@ -578,6 +604,13 @@ window.loadTrackPreset = function(nameOrIdx) { // Force the AI-worker to re-init with the new borders/checkpoints on the // next begin() — otherwise AI cars would also race against stale walls. try { if (typeof invalidateWorkerInit === 'function') invalidateWorkerInit(); } catch (_) {} + // Adaptive gates keep a per-track baseline; without this, Reset / HNSW + // / bad-streak restore would re-paint Triangle green lines on Rectangle. + try { + if (window.AdaptiveGates && typeof window.AdaptiveGates.onTrackChange === 'function') { + window.AdaptiveGates.onTrackChange(); + } + } catch (_) {} try { road.roadEditor.redraw(); } catch (_) { /* redraw only works in phase 1/2 */ } } diff --git a/AI-Car-Racer/uiPanels.js b/AI-Car-Racer/uiPanels.js index 7e6db88..7a18fd2 100644 --- a/AI-Car-Racer/uiPanels.js +++ b/AI-Car-Racer/uiPanels.js @@ -154,7 +154,7 @@ ' dynamics key', '
', ' ', - ' ', + ' ', '
', ' ', ' ', @@ -211,15 +211,15 @@ ' ', ' ', '', - // Dynamics trajectory toggle (P1.C). Off by default — the plan keeps - // this opt-in because it changes retrieval ordering. The count next to - // the label shows how many archived brains have a dynamics vector - // attached; pre-P1.C archives show 0 until a new generation is archived. + // Dynamics trajectory toggle (P1.C). Default ON — empty archive is a + // no-op; once trajectories exist, retrieval can prefer similar drive style. + // The count next to the label shows how many archived brains have a + // dynamics vector attached. '
', ' ', ' ', '
', @@ -1657,6 +1657,16 @@ ' ', '
', '
', + '
', + ' ', + ' nudge/add/remove CPs + crash-map HNSW recall; remember good layouts', + ' ', + '
off
', + '
', '
', '