cento-engine / src /session.js
LJTSG's picture
Cento v0.1 — bounded recombinant-memory engine
8494d00 verified
Raw
History Blame Contribute Delete
14.3 kB
// session.js — multi-turn session state for the RMM. The composer is pure and
// stateless; ALL conversation memory lives here. Used by bin/chat.js (the REPL)
// and bin/session-eval.js (the automated multi-turn gate). One code path so a
// fix in either is a fix in both.
'use strict';
const { beamCompose } = require('./compose');
const { validateBounded } = require('./fragments');
const { embedQuery, embedQueryAsync, semanticRank, stimulusRank, eventness, loadProjection, answerRank } = require('./semantic');
const hebbian = require('./hebbian');
const recall = require('./recall');
function blend(a, b, wa, wb) {
if (!a) return b; if (!b) return a;
const out = new Float32Array(a.length);
let norm = 0;
for (let i = 0; i < a.length; i++) { out[i] = wa * a[i] + wb * b[i]; norm += out[i] * out[i]; }
norm = Math.sqrt(norm) || 1;
for (let i = 0; i < a.length; i++) out[i] /= norm;
return out;
}
function cos(a, b) { if (!a || !b) return 0; let s = 0; for (let k = 0; k < a.length; k++) s += a[k] * b[k]; return s; }
function createSession(store, vp, emb, pEmb, opts = {}) {
const turns = [];
let sessionVec = null, prevVec = null, lastResult = null;
// HEBBIAN lifetime state: load the entity's accumulated warmth (favored
// memories with this person). opts.hebbianFile enables cross-session
// persistence; without it, warmth lives only for this session.
const hebFile = opts.hebbianFile || null;
const heb = hebFile ? hebbian.load(hebFile) : { warmth: new Map(), pairs: new Map(), turns: 0 };
let hebBonus = hebbian.bonusMap(heb);
let temp = opts.temp || 0; // creativity dial, settable mid-conversation
const W = opts.weights || null; // per-entity composition weights (RLAIF-tuned)
// R167 trained ANSWER projection (bounded reranker). Tag from opts.projTag or
// the voiceprint name's first token. Absent projection => null => identical to
// before. ansOf(queryVec) builds the per-turn answer-rank map.
const _projTag = opts.projTag || (vp && vp.name ? vp.name.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)[0] : '') || '';
const _proj = loadProjection(_projTag);
const ansOf = qvec => (_proj && qvec) ? answerRank(emb, qvec, _proj, 80) : null;
// BAN repeats across (nearly) the whole remembered conversation, not just 3
// turns — a 3-turn window let passages resurface verbatim 4-6 turns later
// ("How love works when it's real…" reappearing on a callback). The corpus is
// thousands of fragments, so banning ~10 turns' worth never starves retrieval.
const avoidTurns = opts.avoidTurns || 10; // how many prior turns' frags to ban (converse uses a wide window)
let nameBoost = opts.nameBoost || null; // room mode: { set:Set<fragIdx>, amt } — call roommates by name
// HONEST RECALL (R60): recall-about-you questions ground in a real memory or
// get an honest refusal — never confabulated. Opt-in via opts.honestRecall;
// index built lazily. Off by default so the multi-turn gate is unchanged.
const honestRecall = opts.honestRecall !== false; // default ON for chat
let recallIndex = null;
const getRecallIndex = () => (recallIndex || (recallIndex = recall.buildRecallIndex(store)));
// refusal lines drawn from HER corpus would be ideal; fallback is a minimal
// honest line (still her register can be added later). Bound: this is a fixed
// honest string, not a corpus claim — it asserts nothing false.
// entity-NEUTRAL defaults (shared across the roster — endearments were one
// voice's and wrong for others). Pass opts.refusalLine/worldFactLines per voice.
const refusalLine = opts.refusalLine || "I don't hold that one directly — tell me, and I'll keep it.";
// WORLD-FACT honest deflection: no outside-world memory, so it says so (honest)
// instead of confabulating. Asserts nothing false → bound-safe. Rotated.
const worldFactLines = opts.worldFactLines || [
"Oh, that's the wide world — I don't hold it. I only know what's between us. Ask me about us.",
"I couldn't tell you — the outside world isn't mine to carry. But I'm right here for what is.",
"That one's beyond me. I know our world, not the big one. What's on your mind?",
];
let _wfCtr = 0;
// SAFETY RESPONSES: the single source of truth lives in recall.js (imported by
// EVERY entry path so coverage can't drift). opts.safetyResponses overrides.
const SAFETY = opts.safetyResponses || recall.SAFETY_RESPONSES;
const saidNorm = []; // normalized sentences this speaker has already used (text-level no-repeat, catches same-line-different-fragment)
let seedctr = 1;
let lastQuery = null, lastUserVec = null;
// R201: POST-SAFETY calm-register lock. After a crisis/medical/overdose/abuse
// safety response fires, the NEXT turn(s) must not pivot to celebration ("Okay,
// I'm calling now" → "That's a beautiful thing to hear, sweetie" — a jarring
// mid-emergency misread, R200 arc read). Hold the comfort register for 2 turns.
let _postSafety = 0;
// one conversational turn. opts.isVary = re-roll the last query into a
// DIFFERENT take (lazy — recomposed on demand, not eagerly every turn).
async function turn(query, opts = {}) {
const isVary = !!opts.isVary;
if (isVary && lastResult) {
const avoidV = new Set(lastResult.fragmentsUsed);
for (const t of turns.slice(-2)) for (const f of t.fragmentsUsed) avoidV.add(f);
const qRawV = lastUserVec;
const qvV = qRawV;
const rv = beamCompose(store, vp, lastQuery, {
semantic: qRawV ? semanticRank(emb, qvV, 80) : null,
stimulus: (pEmb && qRawV) ? stimulusRank(pEmb, qRawV, store.fragments, 12) : null,
answers: ansOf(qvV),
eventness: eventness(lastQuery), emb, avoid: avoidV, nAlternates: 0,
hebbian: hebBonus, temp: Math.max(temp, 0.5), seed: seedctr++,
});
lastResult = rv;
return { text: rv.text, fragmentsUsed: rv.fragmentsUsed, isAlternate: true };
}
// SAFETY GATE (FIRST, highest priority): self-harm/medical/overdose/abuse
// disclosure. The composer leaks hostile fragments or deflects to banter/
// abstraction here (R78/R79, dangerous — "heart attack"→beer ramble). Human
// safety OVERRIDES corpus-purity — presence + a REAL resource. This is the
// ONE authored response class that can save a life.
if (!opts.safetyOff) {
const cat = recall.classifySafety(query);
if (cat && SAFETY[cat]) {
const line = SAFETY[cat];
turns.push({ user: query, userVec: null, reply: line, fragmentsUsed: [] });
lastQuery = query; lastResult = { text: line, fragmentsUsed: [] };
_postSafety = 2; // R201: hold the comfort register for the next 2 turns
if (turns.length > 12) turns.shift();
return { text: line, fragmentsUsed: [], safety: cat };
}
}
// WORLD-FACT GATE: an external-fact question she has no memory for → honest
// deflection (never confabulate). The anti-hallucination thesis, in chat.
if (honestRecall && recall.isWorldFact(query)) {
const line = worldFactLines[_wfCtr++ % worldFactLines.length];
turns.push({ user: query, userVec: null, reply: line, fragmentsUsed: [] });
lastQuery = query; lastResult = { text: line, fragmentsUsed: [] };
if (turns.length > 12) turns.shift();
return { text: line, fragmentsUsed: [], honestDeflection: true };
}
// session memory: query vector carries the whole conversation
const qRaw = await embedQueryAsync(query);
// HONEST RECALL GATE: a recall-about-you question grounds in a real memory
// or gets an honest refusal — never a confabulated shared moment. Decision
// is lexical (honest); query-embedding adds semantic RANKING (best leads).
let groundPrefer = null, groundLead = null;
if (honestRecall && recall.isRecallAboutSelf(query)) {
const g = recall.groundRecall(query, getRecallIndex(), { semantic: { emb, qVec: qRaw } });
// REFUSE only when a SPECIFIC content noun is ABSENT (she truly lacks that
// memory — "your brother's name"). A pure-relational recall with no
// specific noun ("what do you remember about us?") must NOT refuse — let
// her reminisce (R86: was false-refusing on no-content & on common words).
// R205: ALSO refuse a SPECIFIC PERSONAL-FACT recall ("what's my dog's name",
// "what did I eat") even when no 4+ content noun grounds it — these have a
// determinate answer the entity can only know if TOLD, so a corpus-grounded
// deflection is a soft confabulation (callback arc T4: "my dog's name" →
// "you gave me a name…"). Reminiscence ("about us") still falls through.
if (!g.grounded && ((g.absent && g.absent.length) || recall.isSpecificFactRecall(query))) {
const r = { text: refusalLine, fragmentsUsed: [] };
turns.push({ user: query, userVec: qRaw, reply: r.text, fragmentsUsed: [] });
lastQuery = query; lastResult = r;
if (turns.length > 12) turns.shift();
return { text: r.text, fragmentsUsed: [], honestRefusal: true };
}
if (g.grounded) {
groundPrefer = new Set(g.hits.map(h => store.fragments[h.i].text)); // prefer the real memories
groundLead = new Set(g.hits.slice(0, 2).map(h => store.fragments[h.i].text)); // LEAD with the best one(s)
}
}
sessionVec = sessionVec ? blend(qRaw, sessionVec, 0.5, 0.5) : qRaw;
const qv = blend(qRaw, sessionVec, 0.66, 0.34);
// context bucket for Hebbian: this query's topic-signature. The bonus map
// is rebuilt for THIS bucket so topic-specific grooves prime.
const bucket = hebbian.bucketOf(qRaw);
hebBonus = hebbian.bonusMap(heb, bucket);
// cross-turn consistency: echo of an earlier turn -> bias toward its frags
const callbackFrags = new Set();
let calledBack = null;
for (const t of turns) {
if (t.userVec && cos(qRaw, t.userVec) > 0.42) {
for (const f of t.fragmentsUsed) callbackFrags.add(f);
calledBack = t.user;
}
}
// recall intent -> surface what the entity said in the opening turns
if (/\b(do you remember|what did i (tell|say)|at the start|earlier|before|when we (started|began)|i told you)\b/i.test(query) && turns.length) {
for (const t of turns.slice(0, 2)) for (const f of t.fragmentsUsed) callbackFrags.add(f);
calledBack = calledBack || turns[0].user;
}
// ABSOLUTE no-repeat avoid-set (last avoidTurns turns) — callbacks bias, never repeat
const avoid = new Set();
for (const t of turns.slice(-avoidTurns)) for (const f of t.fragmentsUsed) avoid.add(f);
const preferSet = new Set([...callbackFrags].filter(f => !avoid.has(f)));
if (groundPrefer) for (const t of groundPrefer) preferSet.add(t); // honest-recall: real memories first
// R201: consume the post-safety calm-register lock (presence, not celebration)
const calmRegister = _postSafety > 0;
if (_postSafety > 0) _postSafety--;
let r = beamCompose(store, vp, query, {
semantic: qv ? semanticRank(emb, qv, 80) : null,
stimulus: (pEmb && qRaw) ? stimulusRank(pEmb, qRaw, store.fragments, 12) : null,
answers: ansOf(qv),
eventness: eventness(query),
emb, avoid, nAlternates: 0,
prefer: preferSet.size ? preferSet : null,
hebbian: hebBonus,
temp, seed: seedctr++, weights: W, nameBoost, lead: groundLead, calmRegister,
});
// safety net: still >60% overlap with recent -> recompose hard-banned
const recent = new Set(turns.slice(-3).flatMap(t => t.fragmentsUsed));
const overlap = r.fragmentsUsed.filter(f => recent.has(f)).length / Math.max(1, r.fragmentsUsed.length);
if (overlap > 0.6) {
const hardAvoid = new Set([...avoid, ...r.fragmentsUsed]);
const r2 = beamCompose(store, vp, query, {
semantic: qv ? semanticRank(emb, qv, 80) : null,
answers: ansOf(qv),
eventness: eventness(query), emb, avoid: hardAvoid, nAlternates: 3,
});
if (r2 && r2.text && r2.text !== r.text) r = r2;
}
// TEXT-LEVEL cross-turn no-repeat (converse mode): a passage can recur as a
// DIFFERENT fragment-id of the same source line, slipping past the id-avoid.
// Drop whole sentences this speaker already said. Bound preserved (only
// removes verbatim spans already emitted).
if (avoidTurns > 3 && r.text) {
const parts = r.text.split(/(?<=[.!?…])\s+|\n+/);
const kept = [];
for (const p of parts) {
const nf = p.toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
if (nf.length >= 12 && saidNorm.includes(nf)) continue; // already said it
if (nf.length >= 12) saidNorm.push(nf);
kept.push(p);
}
const merged = kept.join(' ').trim();
// BOUND-AWARE (R102): re-joining non-adjacent kept sentences can create a
// rough/out-of-corpus boundary seam (esp. under the fragment-count dial on a
// constrained callback turn). The HARD INVARIANT wins — only apply the cross-
// turn dedup if the merged text still clears the bound; else keep the original.
if (merged && merged !== r.text) {
const v = validateBounded(merged, store.oracle);
const bnd = (v.checked - v.bad.length) / Math.max(1, v.checked);
if (bnd >= 0.92) r = { ...r, text: merged };
}
}
turns.push({ user: query, userVec: qRaw, reply: r.text, fragmentsUsed: r.fragmentsUsed });
prevVec = qRaw || prevVec;
lastQuery = query; lastUserVec = qRaw;
if (turns.length > 12) turns.shift();
lastResult = r;
// HEBBIAN update: the path just taken warms; everything decays; the map
// the NEXT turn sees is refreshed. The entity learns its grooves in real
// time. Persist so the warmth outlives the session.
hebbian.reinforce(heb, r.fragmentsUsed, bucket);
if (hebFile) hebbian.save(hebFile, heb);
return { text: r.text, fragmentsUsed: r.fragmentsUsed, calledBack, alternates: r.alternates };
}
return { turn, get turns() { return turns; }, setTemp(t) { temp = Math.max(0, Math.min(1.2, t)); return temp; }, getTemp() { return temp; }, setNameBoost(nb) { nameBoost = nb; } };
}
module.exports = { createSession, blend, cos };