// hebbian.js — FAST-WEIGHTS overlay on the frozen transition graph. The // trigram oracle is the prior (what CAN be said); this is the lived, drifting // state (what this entity TENDS to say, with this person, lately). Cells that // fire together wire together: a fragment used in a reply warms; a pair used // adjacently warms their bond. Warmth decays each turn so it stays a FAST // weight, not a permanent groove. Persisted per-entity → accumulates across // sessions. This is TTT as a legible tensor: weights updating at inference. // // THE BOUND IS UNTOUCHED: Hebbian only biases WHICH licensed fragments surface. // It can never change what's legal. Learn the music, never invent the notes. 'use strict'; const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const DECAY = 0.97; // per-turn multiplicative decay (half-life ~23 turns) const REINFORCE = 1.0; // warmth added to a used fragment const PAIR_REINFORCE = 0.6; // warmth added to an adjacent pair const MAX_BONUS = 0.08; // hard cap on the relevance bonus — primes, never dominates function h(text) { return crypto.createHash('sha1').update(text).digest('hex').slice(0, 12); } // CONTEXT BUCKET (LSH): warmth is conditioned on the KIND of query, so grooves // form per-topic ("fire questions warm fire-memories") instead of one global // favorite dominating. K fixed random hyperplanes → a K-bit sign signature; // queries pointing similar directions share a bucket. Planes are deterministic // (seeded PRNG) so buckets are stable across runs and sessions. const K_PLANES = 7; let PLANES = null; function mulberry32(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; } function ensurePlanes(d) { if (PLANES && PLANES[0].length === d) return; const rng = mulberry32(0x5EED1234); PLANES = []; for (let k = 0; k < K_PLANES; k++) { const p = new Float32Array(d); for (let j = 0; j < d; j++) p[j] = rng() - 0.5; PLANES.push(p); } } function bucketOf(vec) { if (!vec || !vec.length) return 'g'; // no vector → global bucket ensurePlanes(vec.length); let bits = ''; for (const p of PLANES) { let s = 0; for (let j = 0; j < vec.length; j++) s += vec[j] * p[j]; bits += s >= 0 ? '1' : '0'; } return bits; } // load/save take a FULL FILE PATH (kept out of read-only corpus folders — // the product persists to its own cache dir, keyed per entity) function load(filePath) { try { const raw = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^/, '')); return { warmth: new Map(Object.entries(raw.warmth || {})), pairs: new Map(Object.entries(raw.pairs || {})), turns: raw.turns || 0 }; } catch (_) { return { warmth: new Map(), pairs: new Map(), turns: 0 }; } } function save(filePath, heb) { try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); const obj = { warmth: Object.fromEntries(heb.warmth), pairs: Object.fromEntries(heb.pairs), turns: heb.turns }; fs.writeFileSync(filePath, JSON.stringify(obj)); } catch (_) {} } // after a reply: warm its fragments (GLOBAL + this query's BUCKET) + adjacent // pairs, decay everything, prune. The bucket key conditions warmth on context. function reinforce(heb, fragmentTexts, bucket) { bucket = bucket || 'g'; for (const [k, v] of heb.warmth) { const nv = v * DECAY; if (nv < 0.02) heb.warmth.delete(k); else heb.warmth.set(k, nv); } for (const [k, v] of heb.pairs) { const nv = v * DECAY; if (nv < 0.02) heb.pairs.delete(k); else heb.pairs.set(k, nv); } for (let i = 0; i < fragmentTexts.length; i++) { const a = h(fragmentTexts[i]); heb.warmth.set(a, (heb.warmth.get(a) || 0) + REINFORCE); // global const bk = bucket + '\t' + a; heb.warmth.set(bk, (heb.warmth.get(bk) || 0) + REINFORCE); // per-context if (i + 1 < fragmentTexts.length) { const pk = a + ':' + h(fragmentTexts[i + 1]); heb.pairs.set(pk, (heb.pairs.get(pk) || 0) + PAIR_REINFORCE); } } heb.turns++; } // build a fast bonus lookup for compose: fragmentHash -> 0..MAX_BONUS. // Blends GLOBAL warmth (general favorites) with the current BUCKET's warmth // (topic-specific grooves), so context-relevant memories prime hardest. function bonusMap(heb, bucket) { const m = new Map(); if (!heb || !heb.warmth.size) return m; bucket = bucket || 'g'; const prefix = bucket + '\t'; let gMax = 0, bMax = 0; for (const [k, v] of heb.warmth) { if (k.includes('\t')) { if (k.startsWith(prefix) && v > bMax) bMax = v; } else if (v > gMax) gMax = v; } const acc = new Map(); // fragHash -> blended raw score for (const [k, v] of heb.warmth) { if (k.includes('\t')) { if (!k.startsWith(prefix)) continue; const a = k.slice(prefix.length); acc.set(a, (acc.get(a) || 0) + 0.6 * (bMax ? v / bMax : 0)); // bucket groove } else { acc.set(k, (acc.get(k) || 0) + 0.4 * (gMax ? v / gMax : 0)); // global favorite } } for (const [a, raw] of acc) m.set(a, MAX_BONUS * Math.min(1, raw)); return m; } module.exports = { load, save, reinforce, bonusMap, bucketOf, hashText: h };