cento-engine / src /hebbian.js
LJTSG's picture
Cento v0.1 — bounded recombinant-memory engine
8494d00 verified
Raw
History Blame Contribute Delete
5.28 kB
// 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 };