diff --git a/.swarm/memory.db b/.swarm/memory.db new file mode 100644 index 0000000..b901c13 Binary files /dev/null and b/.swarm/memory.db differ diff --git a/.swarm/memory.db-shm b/.swarm/memory.db-shm new file mode 100644 index 0000000..4bcfc14 Binary files /dev/null and b/.swarm/memory.db-shm differ diff --git a/.swarm/memory.db-wal b/.swarm/memory.db-wal new file mode 100644 index 0000000..386f1c7 Binary files /dev/null and b/.swarm/memory.db-wal differ diff --git a/AI-Car-Racer/ruvectorBridge.js b/AI-Car-Racer/ruvectorBridge.js index d5aeb2c..1ad3688 100644 --- a/AI-Car-Racer/ruvectorBridge.js +++ b/AI-Car-Racer/ruvectorBridge.js @@ -500,9 +500,187 @@ export function setQueryDynamicsVec(vec) { _queryDynamicsVec = (vec instanceof Float32Array && vec.length === DYNAMICS_DIM) ? vec : null; } -// ─── archive / retrieve ────────────────────────────────────────────────────── +// ─── audit tap (issue #3 / ADR-001 "ruvector Audit Panel") ─────────────────── +// +// Records every presentation-layer interaction (search / store / vote / embed) +// into a bounded ring buffer that the rv-panel renders as a live "Audit log". +// Off by default: `_auditEnabled` gates ALL recording, so recommendSeeds / +// archiveBrain stay byte-identical (one boolean check) until the 🧪 Experiments +// toggle — or `?audit=1` — calls setAuditEnabled(true). Mirrors the +// setFederationCapturer / setCrosstabListeners subscriber pattern already used +// elsewhere in this file. +// +// Each record is { seq, t, op, args, result, raw, ms } where `raw` is a string +// representation of the literal request payload (ADR-001 Decision 2). Vectors in +// `raw` are head-truncated previews (e.g. `Float32Array(512)[0.12, -0.03, …]`), +// never full payloads, so the buffer stays small. The compact `args`/`result` +// summaries drive the at-a-glance list; `raw` is revealed on row-expand. +const AUDIT_CAP = 200; // ring-buffer size; oldest events fall off +const AUDIT_VEC_PREVIEW = 6; // vector elements kept before truncating +const _auditLog = []; // ring buffer, oldest-first +let _auditSeq = 0; +let _auditEnabled = false; +const _auditSubscribers = new Set(); + +export function setAuditEnabled(on) { _auditEnabled = !!on; return _auditEnabled; } +export function isAuditEnabled() { return !!_auditEnabled; } +// subscribeAudit(fn) — fn(record) is called on every event; returns an +// unsubscribe thunk. Multiple subscribers are allowed (e.g. a panel + a test). +export function subscribeAudit(fn) { + if (typeof fn !== 'function') return () => {}; + _auditSubscribers.add(fn); + return () => { _auditSubscribers.delete(fn); }; +} +export function getAuditLog() { return _auditLog.map(_cloneAuditRecord); } +export function clearAuditLog() { _auditLog.length = 0; } + +// Vector-aware, head-truncated stringifier. Any TypedArray (Float32Array track / +// brain vectors, Uint8Array image bytes) or long numeric Array collapses to +// `Ctor(N)[v0, v1, …]` so a 512-dim request never serialises 512 numbers. +function _auditStringify(value) { + try { + return JSON.stringify(value, (_key, val) => { + const isTyped = ArrayBuffer.isView(val) && !(val instanceof DataView); + const isBigNumArr = Array.isArray(val) && val.length > AUDIT_VEC_PREVIEW && typeof val[0] === 'number'; + if (isTyped || isBigNumArr) { + const head = Array.from(val.slice(0, AUDIT_VEC_PREVIEW)) + .map((n) => Math.round(n * 1e4) / 1e4); + const ctor = (val.constructor && val.constructor.name) || 'Array'; + return ctor + '(' + val.length + ')[' + head.join(', ') + + (val.length > AUDIT_VEC_PREVIEW ? ', …' : '') + ']'; + } + return val; + }); + } catch (e) { + return '[unserializable: ' + (e && e.message) + ']'; + } +} + +// archiveBrain's literal request to ruvector is the *flattened* brain vector +// (`_brainDB.insert(flatten(brain), …)`), not the NeuralNetwork object. Surface +// that flattened form so the raw row shows the actual payload (head-truncated by +// _auditStringify). Only called when audit is on, so the extra flatten is free +// in the default path. +function _auditFlattenBrain(brain) { + try { return flatten(brain); } catch (_) { return '[unflattenable brain]'; } +} + +function _cloneAuditRecord(rec) { + return { + seq: rec.seq, + t: rec.t, + op: rec.op, + args: rec.args && typeof rec.args === 'object' ? { ...rec.args } : rec.args, + result: rec.result && typeof rec.result === 'object' ? { ...rec.result } : rec.result, + raw: rec.raw, + ms: rec.ms, + }; +} + +function _freezeAuditRecord(rec) { + if (rec.args && typeof rec.args === 'object') Object.freeze(rec.args); + if (rec.result && typeof rec.result === 'object') Object.freeze(rec.result); + return Object.freeze(rec); +} + +// Append a record + fan out to subscribers. Only ever reached behind an +// `if (_auditEnabled)` guard in the wrappers below. +function _recordAudit(op, args, result, ms, rawValue) { + const rec = _freezeAuditRecord({ + seq: ++_auditSeq, + t: Date.now(), + op, + args: args || null, + result: result || null, + raw: _auditStringify(rawValue), + ms: Math.round((ms || 0) * 100) / 100, + }); + _auditLog.push(rec); + if (_auditLog.length > AUDIT_CAP) _auditLog.splice(0, _auditLog.length - AUDIT_CAP); + for (const fn of _auditSubscribers) { + try { fn(_cloneAuditRecord(rec)); } catch (e) { console.warn('[rv-audit] subscriber threw', e); } + } +} + +// Audited public wrappers around the *Impl functions. Instrumenting at this +// boundary (not at main.js call sites) captures every caller — including the +// cross-tab _onRemoteBrain re-entry, which routes through archiveBrain() below. +// Most-recent recommendSeeds result, stashed so display-only callers (the +// rv-panel) can reuse it instead of re-issuing the identical query every tick. +// See peekLastRecommendation(). One slot, overwritten per call — negligible. +let _lastRecommendation = null; +export function peekLastRecommendation() { return _lastRecommendation; } + +export function recommendSeeds(trackVec, k = 5) { + const t0 = _auditEnabled ? performance.now() : 0; + const out = recommendSeedsImpl(trackVec, k) || []; + _lastRecommendation = { trackVec, k: k | 0, out }; + if (_auditEnabled) { + _recordAudit( + 'search', + { k: k | 0, trackDim: trackVec instanceof Float32Array ? trackVec.length : null, returned: out.length }, + out.length + ? { topId: out[0].id, topScore: Math.round((out[0].score || 0) * 1e4) / 1e4, topSimPct: Math.round(50 + 50 * (out[0].trackSim || 0)) } + : { topId: null }, + performance.now() - t0, + { fn: 'recommendSeeds', trackVec, k: k | 0 }, + ); + } + return out; +} + +export function findSimilarCircuits(trackVec, k = 5) { + if (!_auditEnabled) return findSimilarCircuitsImpl(trackVec, k); + const t0 = performance.now(); + const out = findSimilarCircuitsImpl(trackVec, k) || []; + _recordAudit( + 'search', + { k: k | 0, trackDim: trackVec instanceof Float32Array ? trackVec.length : null, kind: 'circuits', returned: Array.isArray(out) ? out.length : 0 }, + null, + performance.now() - t0, + { fn: 'findSimilarCircuits', trackVec, k: k | 0 }, + ); + return out; +} export function archiveBrain(brain, fitness, trackVec, generation = 0, parentIds = [], fastestLap, dynamicsVec) { + if (!_auditEnabled) return archiveBrainImpl(brain, fitness, trackVec, generation, parentIds, fastestLap, dynamicsVec); + const t0 = performance.now(); + const id = archiveBrainImpl(brain, fitness, trackVec, generation, parentIds, fastestLap, dynamicsVec); + _recordAudit( + 'store', + { + fitness: Number(fitness) || 0, + generation: generation | 0, + parents: Array.isArray(parentIds) ? parentIds.length : 0, + hasTrack: trackVec instanceof Float32Array, + hasDynamics: dynamicsVec instanceof Float32Array, + remote: _crosstabReceiving, + }, + { id }, + performance.now() - t0, + { fn: 'archiveBrain', brain: _auditFlattenBrain(brain), fitness: Number(fitness) || 0, generation: generation | 0, parentIds, fastestLap, trackVec, dynamicsVec }, + ); + return id; +} + +export function observe(retrievedIds, outcomeFitness) { + if (!_auditEnabled) return observeImpl(retrievedIds, outcomeFitness); + const t0 = performance.now(); + const ret = observeImpl(retrievedIds, outcomeFitness); + _recordAudit( + 'vote', + { ids: Array.isArray(retrievedIds) ? retrievedIds.length : 0, outcomeFitness: Number(outcomeFitness) || 0 }, + null, + performance.now() - t0, + { fn: 'observe', retrievedIds, outcomeFitness: Number(outcomeFitness) || 0 }, + ); + return ret; +} + +// ─── archive / retrieve ────────────────────────────────────────────────────── + +function archiveBrainImpl(brain, fitness, trackVec, generation = 0, parentIds = [], fastestLap, dynamicsVec) { requireReady(); // Phase 3A — F7. Advance the observability generation cursor. This is // a read-only counter exposed via getIndexStats().timings.lastGen so @@ -609,7 +787,7 @@ function upsertTrack(trackVec) { // Returns [{ id, vector, meta, score }, ...] ordered best first. // Caller is expected to unflatten vectors into NeuralNetwork instances. -export function recommendSeeds(trackVec, k = 5) { +function recommendSeedsImpl(trackVec, k = 5) { requireReady(); if (_brainMirror.size === 0) return []; @@ -1192,17 +1370,32 @@ function _frozenSnapshotRef() { // ─── embedding + observation ───────────────────────────────────────────────── // imageData: Uint8Array of RGB bytes (length = width*height*3, no alpha). -export function embedTrack(imageData, width, height) { +function embedTrackImpl(imageData, width, height) { requireReady(); return _cnn.extract(imageData, width, height); } +// Audited wrapper — see the audit-tap section above. No-op overhead when the +// audit panel is off. +export function embedTrack(imageData, width, height) { + if (!_auditEnabled) return embedTrackImpl(imageData, width, height); + const t0 = performance.now(); + const vec = embedTrackImpl(imageData, width, height); + _recordAudit( + 'embed', + { width: width | 0, height: height | 0, bytes: imageData ? imageData.length : 0 }, + { dim: vec ? vec.length : 0 }, + performance.now() - t0, + { fn: 'embedTrack', width: width | 0, height: height | 0, imageData }, + ); + return vec; +} export function cosineSimilarity(a, b) { requireReady(); return _cnn.cosineSimilarity(a, b); } -export function observe(retrievedIds, outcomeFitness) { +function observeImpl(retrievedIds, outcomeFitness) { requireReady(); if (!retrievedIds || retrievedIds.length === 0) return; const normOut = Math.tanh((Number(outcomeFitness) || 0) / 100); @@ -1319,7 +1512,7 @@ export function endPhase4Trajectory(finalFitness) { if (_sonaPaused) return null; try { return sonaEndTrajectory(finalFitness); } catch (e) { console.warn('[sona] endTrajectory failed', e); return null; } } -export function findSimilarCircuits(trackVec, k = 5) { +function findSimilarCircuitsImpl(trackVec, k = 5) { try { return sonaFindPatterns(trackVec, k); } catch (e) { console.warn('[sona] findPatterns failed', e); return []; } } @@ -1883,6 +2076,7 @@ export async function _debugReset() { _observations.clear(); _insertionOrder = []; _queryDynamicsVec = null; + _lastRecommendation = null; // drop the cached seeds so the panel re-queries // Phase 2A — reset federation diagnostic counters. The shadow index is // rebuilt by the next hydrate / hydrateFromFixture call, so we don't // touch _brainDB_hyperbolic here (matching the pre-2A policy that diff --git a/AI-Car-Racer/style.css b/AI-Car-Racer/style.css index 9e249a6..e4c1f8b 100644 --- a/AI-Car-Racer/style.css +++ b/AI-Car-Racer/style.css @@ -2218,6 +2218,90 @@ label { letter-spacing: 0.02em; } +/* === Issue #3 / ADR-001 — ruvector audit log === */ +.rv-audit { width: 100%; } +.rv-audit-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; +} +.rv-audit-counts { + font-size: 0.78em; + color: #9fb0cc; + font-variant-numeric: tabular-nums; +} +.rv-audit-clear { font-size: 0.75em; padding: 1px 8px; } +.rv-audit-list { + max-height: 240px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; +} +.rv-audit-item { + background: rgba(10, 16, 32, 0.5); + border-radius: 3px; + border-left: 3px solid #4a5a7a; +} +.rv-audit-srow { + display: flex; + align-items: baseline; + gap: 6px; + padding: 3px 6px; + cursor: pointer; + font-size: 0.76em; + list-style: none; +} +.rv-audit-srow::-webkit-details-marker { display: none; } +.rv-audit-op { + flex: 0 0 auto; + text-transform: uppercase; + font-weight: 700; + font-size: 0.85em; + letter-spacing: 0.04em; + min-width: 44px; +} +.rv-audit-clock { + flex: 0 0 auto; + color: #7f90ad; + font-variant-numeric: tabular-nums; +} +.rv-audit-text { + flex: 1 1 auto; + color: #cdd8ec; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.rv-audit-ms { + flex: 0 0 auto; + color: #7f90ad; + font-variant-numeric: tabular-nums; +} +.rv-audit-raw { + margin: 0; + padding: 6px 8px; + border-top: 1px solid rgba(120, 140, 180, 0.2); + background: rgba(4, 8, 18, 0.6); + color: #aeb9d0; + font-size: 0.72em; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: normal; + overflow-x: auto; +} +/* Per-op accent colours — match the search/store/vote vocabulary. */ +.rv-audit-op-search { border-left-color: #3a7bd5; } +.rv-audit-op-search .rv-audit-op { color: #6ea8ff; } +.rv-audit-op-store { border-left-color: #3fa66a; } +.rv-audit-op-store .rv-audit-op { color: #62d199; } +.rv-audit-op-vote { border-left-color: #d38b4b; } +.rv-audit-op-vote .rv-audit-op { color: #f0a868; } +.rv-audit-op-embed { border-left-color: #9a6fd4; } +.rv-audit-op-embed .rv-audit-op { color: #c19af0; } + /* === Phase A — Brain saves (named-slot persistence) === */ /* Mounted directly below the legacy Save Best + Restart actions, so the * single-slot legacy buttons and the multi-slot named saves are diff --git a/AI-Car-Racer/uiPanels.js b/AI-Car-Racer/uiPanels.js index 7e6db88..e25c91c 100644 --- a/AI-Car-Racer/uiPanels.js +++ b/AI-Car-Racer/uiPanels.js @@ -1361,13 +1361,23 @@ } catch (_) { /* best-effort */ } } - // Recompute seeds for the badge/list. recommendSeeds is cheap (in-memory - // cosine over a few hundred entries), and only runs when one of the - // above inputs has moved. + // Seeds for the badge/list. The training loop (main.js buildBrainsBuffer) + // already runs recommendSeeds(trackVec, 10) once per generation; rather than + // fire an identical search again just to paint, reuse that result when it + // matches our query (same trackVec identity + at least our k). We only issue + // our own search when the loop hasn't seeded this track yet — e.g. a track + // finalised in phase 3 before training starts. Display-only; the bridge call + // stays a pure read either way. let seeds = []; if (ready && info && info.brains > 0) { try { - seeds = window.__rvBridge.recommendSeeds(trackVec, BADGE_K) || []; + const b = window.__rvBridge; + const last = (typeof b.peekLastRecommendation === 'function') ? b.peekLastRecommendation() : null; + if (last && last.trackVec === trackVec && last.k >= BADGE_K && Array.isArray(last.out)) { + seeds = last.out.slice(0, BADGE_K); + } else { + seeds = b.recommendSeeds(trackVec, BADGE_K) || []; + } } catch (e) { console.warn('[rv-panel] recommendSeeds failed', e); seeds = []; @@ -1596,6 +1606,146 @@ __rvShareRow = shareRow; } + // === Issue #3 / ADR-001 — ruvector audit log === + // A live, reverse-chronological view of every interaction the presentation + // layer has with ruvector (search / store / vote / embed). Each row expands + // to the raw request string the bridge captured (ADR-001 Decision 2). Built + // here, then MOVED into the 🧪 Experiments disclosure by buildExperimentsPanel + // below. Off by default — the bridge records nothing until the toggle (or + // ?audit=1) calls setAuditEnabled, and the panel only subscribes while on. + let __rvAuditRow = null; + let _auditUnsub = null; + let _auditWant = false; // desired enabled-state; guards the boot poll below + let ensureAuditWiring = function () {}; + let teardownAuditWiring = function () {}; + { + const row = document.createElement('div'); + row.className = 'rv-audit'; + row.setAttribute('data-rv', 'audit'); + row.innerHTML = [ + '
', + ' no events yet', + ' ', + '
', + '
', + ].join(''); + + const listEl = row.querySelector('[data-rv="audit-list"]'); + const countsEl = row.querySelector('[data-rv="audit-counts"]'); + const clearBtn = row.querySelector('[data-rv="audit-clear"]'); + const MAX_ROWS = 50; // rendered-row cap; the bridge ring buffer holds the rest + const counts = { search: 0, store: 0, vote: 0, embed: 0 }; + + function fmtClock(t) { + try { return new Date(t).toLocaleTimeString(undefined, { hour12: false }); } + catch (_) { return ''; } + } + function summarize(rec) { + const a = rec.args || {}; + const r = rec.result || {}; + if (rec.op === 'search') { + const dim = (a.trackDim == null) ? '—' : a.trackDim; + const hits = (a.returned != null) ? a.returned : (r.returned || 0); + const top = (r && r.topSimPct != null) ? (' · top ' + r.topSimPct + '%') : ''; + return 'k=' + (a.k != null ? a.k : '?') + ' dim=' + dim + ' → ' + hits + + ' hit' + (hits === 1 ? '' : 's') + (a.kind === 'circuits' ? ' (circuits)' : '') + top; + } + if (rec.op === 'store') { + return 'fit ' + (Math.round((a.fitness || 0) * 10) / 10) + ' · g' + (a.generation || 0) + + ' · p' + (a.parents || 0) + (a.hasDynamics ? ' · +dyn' : '') + + (a.remote ? ' · remote' : '') + ' → ' + (r.id != null ? r.id : '?'); + } + if (rec.op === 'vote') { + const n = a.ids || 0; + return n + ' id' + (n === 1 ? '' : 's') + ' · outcome ' + (Math.round((a.outcomeFitness || 0) * 10) / 10); + } + if (rec.op === 'embed') { + return (a.width || 0) + '×' + (a.height || 0) + ' → ' + (r.dim || 0) + 'd'; + } + return ''; + } + function renderCounts() { + countsEl.textContent = (counts.search + counts.store + counts.vote + counts.embed) === 0 + ? 'no events yet' + : 'search ' + counts.search + ' · store ' + counts.store + ' · vote ' + counts.vote + + (counts.embed ? ' · embed ' + counts.embed : ''); + } + function renderRow(rec, prepend) { + const item = document.createElement('details'); + item.className = 'rv-audit-item rv-audit-op-' + rec.op; + item.innerHTML = [ + '', + ' ', escapeHtml(rec.op), '', + ' ', fmtClock(rec.t), '', + ' ', escapeHtml(summarize(rec)), '', + ' ', rec.ms, 'ms', + '', + '
', escapeHtml(rec.raw || ''), '
', + ].join(''); + if (prepend && listEl.firstChild) listEl.insertBefore(item, listEl.firstChild); + else listEl.appendChild(item); + while (listEl.childElementCount > MAX_ROWS) listEl.removeChild(listEl.lastChild); + } + + clearBtn.addEventListener('click', function () { + const b = window.__rvBridge; + try { if (b && typeof b.clearAuditLog === 'function') b.clearAuditLog(); } catch (_) {} + listEl.innerHTML = ''; + counts.search = counts.store = counts.vote = counts.embed = 0; + renderCounts(); + }); + + // Enable recording + subscribe. uiPanels.js runs BEFORE the async ruvector + // sidecar assigns window.__rvBridge (see index.html), so on a slow + // fetch/import the bridge — and setAuditEnabled/subscribeAudit — may not + // exist yet when the ?audit=1 preset (or an early click) fires. Keep polling + // for as long as the user still wants audit on (`_auditWant`), so an + // arbitrarily slow import still gets wired the moment it lands — toggling + // OFF clears `_auditWant` and ends the loop. Backs off from 100 ms to 1 s + // after the first few seconds so a bridge that never loads can't spin hot. + // A re-entrant call (e.g. toggle off→on) is a no-op once `_auditUnsub` is set. + ensureAuditWiring = async function () { + if (_auditUnsub) return; // already wired + _auditWant = true; + let attempt = 0; + while (_auditWant && !_auditUnsub) { + const b = window.__rvBridge; + if (b && typeof b.subscribeAudit === 'function' && typeof b.setAuditEnabled === 'function') { + try { b.setAuditEnabled(true); } catch (e) { console.warn('[rv-audit] setAuditEnabled failed', e); } + // Backfill anything recorded between enable and wiring (counts reflect + // the whole buffer; only the last MAX_ROWS are rendered, newest on top). + try { + const existing = (typeof b.getAuditLog === 'function') ? b.getAuditLog() : []; + listEl.innerHTML = ''; + counts.search = counts.store = counts.vote = counts.embed = 0; + for (const rec of existing) if (counts[rec.op] != null) counts[rec.op]++; + for (const rec of existing.slice(-MAX_ROWS)) renderRow(rec, true); + renderCounts(); + } catch (_) {} + _auditUnsub = b.subscribeAudit(function (rec) { + if (counts[rec.op] != null) counts[rec.op]++; + renderRow(rec, true); + renderCounts(); + }); + return; + } + attempt++; + await new Promise((res) => setTimeout(res, attempt < 30 ? 100 : 1000)); + } + }; + teardownAuditWiring = function () { + _auditWant = false; + if (_auditUnsub) { try { _auditUnsub(); } catch (_) {} _auditUnsub = null; } + try { + const b = window.__rvBridge; + if (b && typeof b.setAuditEnabled === 'function') b.setAuditEnabled(false); + } catch (_) {} + }; + + renderCounts(); + __rvAuditRow = row; + } + // === Phase A — UI discoverability pass: 🧪 Experiments disclosure === // // Consolidates the RuLake-inspired feature toggles into one collapsible @@ -1635,6 +1785,16 @@ ' flame-graph-lite for every generation', ' ', ' ', + '
', + ' ', + ' every search / store / vote, with the raw request', + ' ', + ' ', + '
', '
', '